Skip to content

feat: #164 sponsored ERC-4337 register + v2-demo harness restructure#200

Merged
hanwencheng merged 14 commits into
mainfrom
claude/trusting-montalcini-51bff9
Jun 5, 2026
Merged

feat: #164 sponsored ERC-4337 register + v2-demo harness restructure#200
hanwencheng merged 14 commits into
mainfrom
claude/trusting-montalcini-51bff9

Conversation

@hanwencheng

Copy link
Copy Markdown
Member

Summary

Two strands developed together on this branch:

  1. Migrate master authority to an ERC-4337 P-256 smart-account (resolves §11 gating findings) #164 broker-sponsored ERC-4337 master register — gas-free passkey master onboarding (E6/E7).
  2. v2-demo harness restructure + runbook fixes — the operator / sandbox / CI demo flow.

Broker (deploy-relevant)

  • New crates/agentkeys-broker-server/src/sponsor.rs (Stage A): the verifiable encoding + co-sign core for a VerifyingPaymaster-sponsored UserOp — pure functions, no chain client. user_op_hash ≡ EntryPoint.getUserOpHash, paymaster_get_hash ≡ VerifyingPaymaster.getHash, and broker_cosign recovers to brokerSigner. The broker EIP-191-co-signs the paymaster getHash (the Sybil gate) only for an authenticated J1 session.
  • lib.rs: +pub mod sponsor;. No broker route/handler changed — the running broker's HTTP behavior is unchanged; the module is exported for the sponsored-register flow but not yet wired to a live endpoint.
  • Stage B (EntryPoint.handleOps submission — needs an EVM client) is a follow-up, not in this PR.

CLI / daemon

  • CLI: k11 webauthn passkey keygen/sign + the sponsored-register flow.
  • Daemon: ui_bridge wires the flow into the desktop UI.

Harness + docs

  • harness/v2-demo.sh: single 5-phase front door (1-3 stages, 4 memory-plant, 5 wire) with PHASE.STEP addressing (--from 4.1, --only 3.11). Sandbox auto-detect now probes the aiosandbox HTTP API ($SANDBOX_URL/healthz/v1/sandbox) instead of a local openviking install.
  • v2-stage3: agent-side steps (11-12 / 14-15) defer to the sandbox on the operator (green, never fail); mock agent is CI-only. Clearer stale-broker guidance on the feat(broker,worker): skip scope check for master-self (operator==actor) #195 master-self step.
  • v2-stage1/2: Touch-ID WebAuthn by default for operators, stub for CI.
  • New harness/CLAUDE.md (harness rules extracted from root CLAUDE.md), operator runbooks (operator-runbook-harness.md, operator-runbook-web-memory.md), and erc4337 register/fund helpers.

Deploy note

The step-16 (#195 master-self scope skip) fix is already on origin/main (commit 5bd3bf0) — the prod broker just needs a redeploy (bash scripts/setup-broker-host.sh --ref main on the broker host). This PR adds the dormant sponsor module on top; redeploying after merge picks it up with no behavior change to existing routes.

Verification

  • cargo check (broker + cli + daemon): ✅
  • cargo test -p agentkeys-broker-server: ✅ 7 passed (SES integration test ignored — needs live AWS)
  • bash -n on all touched harness scripts: ✅

🤖 Generated with Claude Code

…s restructure

Broker-sponsored, gas-free ERC-4337 master onboarding (#164 E6/E7): new broker 'sponsor' module — verifiable UserOp + VerifyingPaymaster.getHash encoding with an EIP-191 broker co-sign (pure functions, byte-exact with the live contracts, zero-gas read-only verification); lib.rs exports it. CLI gains k11 webauthn passkey keygen/sign + the sponsored-register flow; daemon ui_bridge wires the flow into the desktop UI.

Harness + docs: v2-demo.sh restructured into a single 5-phase front door (1-3 stages, 4 memory-plant, 5 wire) with PHASE.STEP addressing; sandbox auto-detect now probes the aiosandbox HTTP API (not a local openviking install). v2-stage3 agent-side steps (11-12/14-15) DEFER to the sandbox on the operator (green) with mock reserved for CI; clearer stale-broker guidance on the #195 master-self step. stage1/2 default to Touch-ID WebAuthn for operators, stub for CI. Adds harness/CLAUDE.md (harness rules extracted from root CLAUDE.md), operator runbooks (harness, web-memory), and erc4337 register/fund helpers.
… don't trip a stale Cargo.lock

setup-broker-host.sh --ref did 'git checkout -f' + 'git pull --ff-only', which can no-op against a stale local branch tip or leave a build-modified Cargo.lock on disk. A subsequent 'cargo build --locked' then fails with 'cannot update the lock file'. A deploy target must match origin EXACTLY — replace the ff-only pull with 'git reset --hard origin/$PULL_REF' (HEAD + index + working tree, Cargo.lock included). Idempotent.
…nt -> success)

Closes the gap where only the NEGATIVE/skip scope paths were asserted. Worker verify.rs: two new unit tests with an in-process std::net JSON-RPC mock (no new dep, Cargo.lock untouched) — check_chain_scope_ok_when_chain_grants (operator!=actor, chain returns true -> Ok) and check_chain_scope_rejects_when_chain_denies (false -> NotInScope). Harness v2-stage3: new standalone step 18 'POSITIVE: granted agent (operator!=actor) mints memory cap for the GRANTED service -> 200' — extracts the scope-grant assertion out of the steps 11-12 roundtrip; operator-authenticated mint (no agent key), defers on a §10.2 agent whose device isn't paired yet, mocks on CI. Completes the scope triad with step 16 (master-self skip) + step 17 (cross-actor denied). Cleanup renumbered 18->19, STEP_TOTAL=19. Runbook updated.
…{evm_address} (HTTP 422)

Phase 4 step 2 POSTed {evm_address:...} to /v1/auth/wallet/start, but the broker's WalletStartRequest requires {address: String, chain_id: u64} (both mandatory) — so axum rejected it with 422 'missing field address'. Reproduced live: {evm_address} -> 422, {address,chain_id} -> 200. Aligns memory-plant with the broker contract + the shape stage-1/stage-3/web-memory-bootstrap already use. Broker was correct; this was a stale client field name.
…P 400 malformed address)

DEPLOYER_ADDR is already 0x-prefixed (cast wallet address output), but step 2 prepended another 0x -> '0x0x941cb1…' -> broker 400 'malformed address'. The wrong field name (422, prior fix) had masked this. Reproduced live: 0x0x… -> 400, 0x… -> 200. The omni (line 74) + cap-mint (line 98) already use the correct forms (broker hashes agentkeysevm+0x-addr, verified in omni_account.rs). Also switch the wallet start/verify curls from -sSf to -sS --fail-with-body so the broker's error JSON is shown on 4xx instead of an opaque 'curl: (NN) … error: CODE' (this step hid its cause twice).
[high] Bash 3.2 (memory-plant-demo.sh): dropped the 'declare -A CAP' associative array (bash 4+; the operator platform is macOS bash 3.2.57 where it errors and CAP[$ns] under set -u is an unbound arithmetic var). Step 3 now just proves cap-mint per namespace; step 4 re-mints fresh (short-TTL). Verified runnable under 3.2.

[high] Partial plant (daemon ui_bridge.rs): the real-chain plant only failed when ZERO entries planted, so a partial (some namespaces succeed, one fails) returned 200 + audit + updated state. Now ANY durable-write failure returns 502 before the success audit/response; succeeded writes stay in master_memory so a re-plant is idempotent and resumes. 35 ui_bridge tests still pass.

[medium] Phase 5 skip (v2-demo.sh): an auto-skip (no aiosandbox) returned 0 and printed 'all green' + 'agent paired' — an unexecuted proof read as a pass. Now run_wire_phase records WIRE_RESULT (wired/skipped/disabled); an auto-skip reports 'v2-demo INCOMPLETE' and exits non-zero, the loop shows the phase as SKIPPED (not green ok), and the final pass/paired text only prints when the wire actually ran. --wire none is the explicit clean-skip escape (CI uses it). Also fixed the skip hint to the correct aiosandbox 'docker run …' (was the wrong openviking-sandbox-setup.sh). Runbook + harness/CLAUDE.md synced.
…eferred) + real memory plant

dev.sh launched 'agentkeys-daemon --ui-bridge' with NO --register-master-script, so finish_chain_register hit its 'register_master_script = None' branch and silently SKIPPED the on-chain registerFirstMasterDevice (chain: none) — the ceremony was deferred while K11 enroll still reported success. It also passed no --memory-url/--memory-role-arn, so the plant button fell back to the in-memory RwLock instead of cap-mint → STS → worker → S3.

dev.sh now sources scripts/operator-workstation.env and ALWAYS passes --register-master-script (in-repo heima-register-first-master.sh; a missing deployer key / chain config now surfaces chain_error, never a silent skip), plus --memory-url/--memory-role-arn/--region when the env supplies them (real plant; logged). The daemon↔script arg contract was already correct (--operator-omni/--actor-omni/--k11-cose-hex/--k11-cred-id/--rp-id-hash) and real_memory_ctx sources the device hash from the K11-finish register, so the un-deferred ceremony is exactly what feeds the plant.

Name drift called out: the daemon's --memory-url env is AGENTKEYS_MEMORY_URL but operator-workstation.env spells it AGENTKEYS_WORKER_MEMORY_URL; bridged in dev.sh via the explicit flag (accepts either). Also un-stale the ui_bridge.rs module doc that still claimed the register is stubbed.
…e agent's memory scope)

Phase 5 ran 'phase1-wire-demo.sh --real' with NO --webauthn, so the wire's P.3 scope grant (heima-scope-set --webauthn) was SKIPPED — the §10.2 agent paired but the master never granted it the memory:<ns> scope. The agent's memory.get(travel) mints a cap for service 'memory:travel' (mcp-server/src/tools/memory.rs: format!("memory:{namespace}")), the broker checks isServiceInScope(O_master, agent, memory:travel) and returns service_not_in_scope -> Act1 (3.1) + inject (4.2) fail. Now auto + real pass --real --webauthn so the master grants memory:<ns> via Touch ID (one prompt, like phases 1-2; heima-scope-set is idempotent so re-runs skip). Service strings match (grant + cap both memory:<ns>); the master's K11 is enrolled+registered in phases 1-2 so setScopeWithWebauthn verifies. Runbook + harness/CLAUDE.md synced.
…nnect a daemon' toast

The /memory plant button only renders when the daemon is connected (status.kind==='connected'), so a plant failure is almost never 'no daemon' — yet plantDone's else-branch always showed 'Connect a daemon to plant prepared memory', masking the daemon's actual reason (which postJson already captured in r.status.detail, e.g. 409 'no master session — complete onboarding first' / 'master device not registered on chain yet', or a 502 worker error). Now it extracts + shows the real reason. tsc --noEmit clean.
…lant 400)

memory_put_real/memory_get_real send operator_omni + actor_omni = ctx.omni, sourced from the onboarding session omni which is stored BARE (no 0x). The broker cap-mint input-validates that operator_omni starts with 0x and 400s ('operator_omni must start with 0x') before normalizing — so the web plant failed AFTER the device was registered. Normalize ctx.omni to 0x once in real_memory_ctx (covers put + get); the broker normalize_hex32's it for the device-binding match, and master-self (operator==actor) hits the #195 skip so no scope grant is needed. cargo check -p agentkeys-daemon clean.
…s-store gap)

After a successful plant, plantDone read listMasterMemory but only setMemories on ok and toasted just the plant counts — so a daemon-cache miss (e.g. after a restart) silently showed an empty list. Now the toast shows '<planted> new … <list.length> in the memory view' (so 'N new but 0 in view' is visible) and surfaces a failed list GET. Note: GET /v1/master/memory reads the daemon IN-MEMORY cache, not S3 — so the list is empty after any daemon restart even though the data is durable in S3.
… (Phase 0)

Phase 0 of docs/plan/web-flow/config-data-class-memory-list.md (lazy, config-driven memory list). Adds the DataClass::Config variant to both cap.rs + verify.rs (serializes 'config'), the broker cap_config_store/cap_config_fetch handlers (statically derive {op, data_class: Config}) + routes /v1/cap/config-store + /v1/cap/config-fetch. check_data_class is generic, so a Config cap is rejected by the cred + memory workers (and a memory cap by the config worker) — covered by new unit tests. Infra-free: the endpoints mint Config caps, but the config bucket/role/worker land in Phases 1-2. cargo check (broker) clean; 4 worker data_class tests pass.
harness-ci 'cargo fmt + clippy + test' failed at fmt: this PR's sponsor/webauthn/cli/daemon/verify code (committed earlier) wasn't rustfmt-clean, plus my new config routes in lib.rs. Ran cargo fmt --all (6 PR files reformatted, no unrelated drift). Also fixed clippy::unusual_byte_groupings in sponsor.rs:189 (0x0102_03 -> 0x010203, value-identical) that -D warnings rejected in the lib-test target. Verified locally: fmt --check clean, clippy --workspace --all-targets -- -D warnings exit 0, cargo test --workspace 40 results ok / 0 failed.
… seed seam (W6)

Implements wire-real-paths W6 as a v2-demo PHASE, not a standalone script (no second front door, no re-bootstrap). daemon: add --ui-bridge-seed-session-jwt + --ui-bridge-seed-omni — seeds the ui-bridge onboarding session with the master's existing J1 + omni so the parity phase drives the REAL plant chain WITHOUT re-running interactive email/WebAuthn onboarding (pairs with the existing --master-device-key-hash). harness/web-parity-demo.sh = phase 6: reuses the preflight build + live chain/broker + the master registered in phases 1-2, boots agentkeys-daemon --ui-bridge SEEDED, plants a probe ns via POST /v1/master/memory/plant; a 200 proves the daemon's chain (cap-mint→STS→worker→S3) == the agent/harness chain — the web↔harness drift gate. Cost: one daemon boot + one plant, no re-build/re-chain/re-enroll; real-only (skips without a broker). Wired into v2-demo (default 1→6, --from/--stage/--only addressing). Docs synced (runbook, harness/CLAUDE.md, wire-real-paths W6). cargo fmt+clippy --workspace --all-targets clean; bash -n clean. NOTE: statically verified (compiles + wired + prereqs met); the live end-to-end smoke is bash harness/v2-demo.sh --stage 6 on real infra.
@hanwencheng hanwencheng merged commit 6d916e7 into main Jun 5, 2026
8 checks passed
hanwencheng added a commit that referenced this pull request Jun 5, 2026
…rker chain

The broker/worker HTTP chain was hand-coded in three places (MCP HttpBackend,
daemon ui_bridge, harness bash), the structural cause of the #200 drift bugs
(evm_address vs {address,chain_id}, bare-vs-0x omni, per-namespace field
shapes). Collapse it behind one crate so drift is a COMPILE error (Rust callers
share the types) or a FIXTURE mismatch (the harness gate), not a runtime 4xx.

New crate agentkeys-backend-client (the dual of broker-server / worker-*):
- protocol.rs: every cap-mint / worker / audit wire shape, the memory:<ns>
  service builder, and the 0x-omni normalizer (the daemon's old inline bug site)
- client.rs: BackendClient — cap-mint (4 data-class endpoints) -> STS relay ->
  worker put/get -> audit append (the reference impl lifted out of HttpBackend)
- fixtures.rs + dump-protocol-fixtures bin: canonical fixtures serialized from
  the serde types + frozen key-set pins

Collapse the duplicates (net -355 LOC in existing files):
- MCP HttpBackend -> thin delegate over BackendClient; backend wire-shape
  submodules (broker/memory/audit) deleted, re-exported from the crate so the
  Backend trait + InMemoryBackend + tools keep their crate::backend::* paths
- daemon memory_put_real / real_memory_ctx -> call the shared client (kills the
  duplicate cap-mint body + the inline 0x-normalize where the bugs lived)

Enforce (fold-systemic-fixes-into-enforcement):
- scripts/check-backend-fixture-drift.sh: diffs every # @backend-fixture-
  annotated bash body against the crate-emitted fixtures (catches add/rename/drop)
- harness-ci.yml rust-checks runs the fixture --check + the bash gate on every PR
  touching crates/**, harness/**, scripts/**
- root CLAUDE.md + harness/CLAUDE.md "broker/worker shapes have ONE owner" rule;
  arch.md component inventory updated
hanwencheng added a commit that referenced this pull request Jun 6, 2026
…y list + lazy detail + codex hardening) (#205)

* feat(worker-config): #201 Config data-class substrate — infra + worker + isolation tests (Phases 1-3)

Stand up the DataClass::Config substrate end-to-end (Phases 1-3 of the
config-driven memory-list plan; Phase 0 cap layer landed in #200). The
visible daemon/frontend behavior (Phases 4-5) is a follow-up, gated on the
operator deploying this (per the issue's dependency chain 4 -> 0,1,2).

Phase 1 — infra (idempotent mirrors of the memory scripts):
- scripts/provision-config-bucket.sh, provision-config-role.sh,
  apply-config-bucket-policy.sh (config/ prefix, own bucket + role per arch.md
  §17.2; split-statement v3 bucket policy)
- CONFIG_BUCKET / CONFIG_ROLE_ARN + config worker host/URL in
  operator-workstation.env; wired into setup-cloud.sh step 13

Phase 2 — config worker (master-only):
- new agentkeys-worker-config crate (mirror of agentkeys-worker-memory; config/
  S3 prefix, $CONFIG_BUCKET, AGENTKEYS_CONFIG_KEK_HEX, DataClass::Config, :9096)
- full setup-broker-host.sh wiring (build/install/env/systemd/nginx/firewall/
  certbot/post-install summary)

Phase 3 — isolation tests (test-discipline rule):
- harness/v2-stage3-demo.sh steps 19-21: config layer-3/4 (own-prefix write OK +
  cross-bucket AccessDenied) + cap data-class-mismatch (config<->memory,
  config<->cred). All master-self -> run on the operator, no sandbox defer;
  skip cleanly until the operator provisions config infra + redeploys the broker.

Source-of-truth updates: arch.md (§5 canonical names, §17.2/.3/.5, four-layer
table, storage diagram), CLAUDE.md (per-data-class table + six cap endpoints +
'third data class landed'), operator-runbook-harness.md, harness/CLAUDE.md, plan doc.

Verified: config worker dev+release build + unit tests green; cargo check --workspace
clean (all 17 crates); all bash scripts syntax-clean.

* fix(harness): graceful skip (not die) when a cross-class worker is unreachable

post_cross_class folded curl's stderr into the returned code via 2>&1, so an
UNDEPLOYED worker (e.g. config.litentry.org before the broker redeploy) yielded
rc="curl: (35) SSL_ERROR_SYSCALL...\n000000" instead of a clean "000". That no
longer matched master_cross_class_rejection's 000|502|503|504) case and fell
through to die — turning the intended graceful prereq_missing
(config-worker-unreachable) at stage-3 step 21 into a hard failure.

Send curl's transport error to a side file so rc is just the 3-digit %{http_code}
(000 on transport failure), and surface that error as the body for diagnostics.
Also hardens steps 14-15 (same helper) — clean rc + diagnostic body.

Verified: repro against the unreachable config.litentry.org returns clean 000 ->
prereq_missing fires; bash -n clean.

* fix(infra): #201 wire config worker into DNS + worker verify (config.litentry.org A record)

The config worker host was added to operator-workstation.env but NOT to the two
DNS provisioning paths nor the worker health-check, so config.litentry.org never
got an A record → unreachable (the stage-3 step-21 SSL_ERROR_SYSCALL).

Add WORKER_CONFIG_HOST everywhere the four original workers are enumerated:
- scripts/setup-cloud.sh do_step_6 — the PRIMARY DNS path (its own change-batch,
  not a delegate): + config A record + env validation (8 A records / 14 UPSERTs).
- scripts/dns-upsert-workers.sh — the standalone re-UPSERT path: + config in the
  sanity loop, change-batch, plan printout, DoH verify loop, and certbot next-steps.
- scripts/verify-workers.sh — + config:/healthz ("ok":true), All 5 workers green.
- operator-workstation.env — comment now says five workers incl. config.

Verified: bash -n clean on all three; setup-cloud change-batch builds 14 records;
dns-upsert change-batch valid JSON.

* refactor(infra): #201 setup-cloud delegates worker DNS to dns-upsert-workers.sh (single source of truth)

Wire the config-worker setup fully into the idempotent orchestrator so nobody
runs DNS by hand, and kill the dual-maintenance drift that left config.litentry.org
without an A record (two hardcoded worker lists: setup-cloud step 6 + dns-upsert).

- dns-upsert-workers.sh: new --no-verify (UPSERT then exit, skipping the INSYNC/DoH
  wait + operator next-steps printout) for orchestrator use.
- setup-cloud.sh step 6: keep DKIM/MX/TXT + broker/signer/mcp inline (9 records);
  DELEGATE the 5 service-worker A records (audit/email/cred/memory/config) to
  dns-upsert-workers.sh --eip $EIP --no-verify (honors --dry-run + the same
  ENV_FILE so the prod/test split carries through). One source of truth → a new
  worker can never again be added to one list but not the other.
- The 3 config provision scripts were already delegated in step 13 (no change).
- cloud-bootstrap.md: config.litentry.org added to the certbot recipe (+ explicit
  one-shot form), the --config-host flag, the DNS A-record list, the worker-subdomain
  table, the per-worker env-file glob, the build/nginx/test-subdomain references.

Verified: bash -n clean on all three; setup-cloud inline batch builds 9 records;
dns-upsert --no-verify parses + early-exits; cloud-bootstrap certbot loop includes CONFIG_HOST.

* perf(deploy): #201 sccache compiler cache in setup-broker-host (fast re-deploys + branch switches)

The broker host redeploys often and switches branches via --ref. cargo already
caches deps in $REPO_ROOT/target (we never clean on the happy path), but a
git checkout -f rewrites changed files' mtimes → cargo re-fingerprints + rebuilds
them, and a cold/wiped target/ recompiles the whole aws-sdk/tokio tree.

Add sccache — a CONTENT-addressed compiler cache keyed on each crate's actual
inputs (not mtime/branch/target state), persisted in $SCCACHE_DIR independent of
target/. Identical inputs hit the cache regardless of branch or a cold target/.

- setup_build_cache(): installs sccache (prebuilt musl binary, arch-detected →
  cargo install fallback → skip), exports RUSTC_WRAPPER + SCCACHE_DIR, starts the
  server. Best-effort + idempotent + NON-FATAL (deploy proceeds with plain cargo
  if install fails). Opt out: AGENTKEYS_NO_SCCACHE=1; pin: SCCACHE_VERSION=vX.Y.Z.
- Prints 'sccache stats' after the worker build — visible proof (re-deploys =
  mostly cache hits).
- cloud-bootstrap.md documents the cache + the opt-out.

Verified: bash -n clean.

Note: this does NOT change what gets built; my earlier #201 commits were all
shell/docs (zero Rust), so a re-run that only pulls them recompiles nothing.

* docs(cloud-bootstrap): #201 cert issuance MUST run on the broker (not a VPN'd laptop) + ACME pre-check

Operator hit a certbot 'unauthorized … 404' on config.litentry.org because
certbot --webroot was run on a local box (behind a VPN): the challenge file
landed there, but Let's Encrypt validates against the hostname's PUBLIC IP = the
broker, which had no such file. The nginx 1.28.3 (VPN proxy) vs 1.24.0 (broker)
version split in the 404 pages was the tell.

Fold-back to §5b so the next operator can't repeat it:
- Loud '⚠️ run EVERY command ON THE BROKER HOST' callout explaining the
  --webroot-writes-local vs CA-validates-public-IP mechanism + the WARP/Zscaler
  interception trap (laptop curl of <host> hits the VPN's nginx, not the broker).
- A cheap local ACME pre-check (nginx reload + probe file + curl localhost with
  Host header) BEFORE the certbot loop — a freshly-added worker (config) needs a
  reload; 'nginx -T' showing the vhost does NOT mean the running process loaded it.
- New troubleshooting entry for the exact 'unauthorized … 404' error covering both
  causes (wrong host; vhost not reloaded).

Docs only; fences balanced.

* fix(infra): #201 dns-upsert derives worker EIP from broker's A record (not 'first associated EIP')

Root cause of the config (and all-worker) cert failures: dns-upsert-workers.sh
derived the EIP via `describe-addresses | first`, which can't distinguish the
PROD broker EIP from the TEST broker EIP when both are allocated. It silently
grabbed the test EIP (3.214.219.209) and pointed all 5 worker A records at the
test broker, while broker/signer stayed on prod (54.164.117.252). Let's Encrypt
then validated config.litentry.org against the test box (404).

Derive the workers' EIP from BROKER_HOST's OWN Route 53 A record instead — the
workers co-locate with the broker, so their records MUST mirror it. This is
env-aware (BROKER_HOST is broker.${ZONE} for prod vs test-broker.${ZONE} for test)
and authoritative. Add a co-location guard that warns when the chosen/passed EIP
disagrees with the broker's A record (catches a prod/test mixup early).

cloud-bootstrap.md §5b gains a troubleshooting entry for 'worker cert fails but
broker works' with a DoH cross-check loop.

Verified live (--dry-run against the real zone): derives 54.164.117.252 and sets
all 5 worker records to it; bash -n clean.

* fix(infra): #201 dns-upsert derives EIP by broker tag (prod vs CI/test), matching setup-cloud step 4

Prod and the CI/test broker are SEPARATE machines with SEPARATE EIPs. The previous
fix derived from broker.${ZONE}'s A record (works for prod, but chicken-egg on a
fresh test box + a different mechanism than the bootstrap). Switch to the SAME
tag-based, TEST_MODE-aware derivation setup-cloud.sh step 4 uses — one source of
truth:
  prod  → describe-addresses --filters Name=tag:Name,Values=agentkeys-broker-eip
  test  → ...Values=agentkeys-broker-eip-test   (--test, or a *test* ENV_FILE)

- New --test flag + auto-detect from a *test* ENV_FILE (switches to
  operator-workstation.test.env), mirroring setup-cloud.
- Keep the broker-A-record co-location cross-check as a warn-only guard.

Verified live (--dry-run): prod → 54.164.117.252 (tag agentkeys-broker-eip);
--test → 3.214.219.209 (tag agentkeys-broker-eip-test). bash -n clean.

* docs(CLAUDE): #201 always verify the broker IP env-aware (prod vs CI/test = separate EIPs)

Two broker EC2 instances exist with separate EIPs, distinguished by the EIP Name
tag (agentkeys-broker-eip vs agentkeys-broker-eip-test). 'describe-addresses
first-match' silently picks the wrong one — it pointed all 5 worker A records at
the test broker while broker/signer were on prod (multi-round LE 404s). New AWS-
gotchas subsection: never first-match; derive by the env-aware tag (setup-cloud
step 4 / dns-upsert-workers.sh), curl ifconfig.me on the host, DoH-cross-check
workers == broker for DNS.

* perf(deploy): #201 keep Rust toolchain across broker re-deploys (the real slow-rebuild cause)

setup-broker-host.sh deleted /root/.cargo + /root/.rustup at the END of every run
(~1.5GB reclaim). So every re-deploy re-downloaded the WHOLE rustup toolchain +
all 372 crate sources — minutes of pure waste (target/ persists in the repo dir,
which is why the compile itself was only ~50s, but the toolchain+registry did not).

- KEEP the toolchain by default; gate the delete behind a new --reclaim-toolchain
  flag (pass it on a final/one-shot deploy to free the disk).
- Pre-source $HOME/.cargo/env in the build-prereqs step so a kept toolchain is on
  PATH on a non-login sudo shell — otherwise `have rustup` is false and it
  reinstalls anyway even with /root/.cargo present.
- Header usage + post-run NOTE updated to reflect keep-by-default.

Combined with the sccache change (86d18be), re-deploys now skip toolchain DL +
crate-registry DL + most recompilation. bash -n clean.

* refactor(deploy): #201 hard rule — 3 idempotent entry points + --ci env flag

Per the deploy-script governance: there are exactly THREE idempotent deployment
orchestrators (setup-cloud.sh / setup-broker-host.sh / setup-heima.sh); every
other mutation is wired into one of them. Codify it in CLAUDE.md + standardise the
environment flag.

- Add --ci (canonical CI-env flag; --test retained as alias) to all 3 entry points
  + dns-upsert-workers.sh. Plain run = local/prod; --ci = CI (selects the
  agentkeys-broker-eip-test EIP, -test IAM/buckets, *.test.env).
- CLAUDE.md: new 'Three idempotent deployment entry points' section (ownership
  table, flag convention, HARD wire-in rule, exempt list). Verified mcp-host is
  already wired into setup-broker-host (#152 re-converge); setup-dev-env is a
  dev-workstation bootstrap (exempt, not a deploy).

Verified: bash -n clean; --ci --dry-run derives the test EIP (3.214.219.209).

* fix(infra): #201 test stack — config data class in test env + step-13 ENV_FILE passthrough + cloud-bootstrap --ci

Two real test-mode bugs + doc drift, found while fitting the scripts to cloud-bootstrap.md:

- operator-workstation.test.env was MISSING the entire config (#201) data class
  (CONFIG_ROLE_ARN / CONFIG_BUCKET / WORKER_CONFIG_HOST / *_URL) — so
  setup-cloud.sh --ci / setup-broker-host.sh --ci would die on the WORKER_CONFIG_HOST
  validation. Added the -test trio (agentkeys-config-role-test, agentkeys-config-test-<acct>,
  config-test.litentry.org).
- setup-cloud.sh step 13 called provision/apply-*.sh WITHOUT ENV_FILE; each re-sources
  operator-workstation.env (prod) and overwrites inherited CONFIG_BUCKET, so --ci would
  silently provision PROD buckets. Now passes ENV_FILE through (DRY loop) → -test buckets.
- cloud-bootstrap.md: --test → --ci (alias noted) in quick-start; added config bucket to
  'what --ci derives'; corrected the stale 'toolchain deleted each run' note to the new
  keep-by-default + --reclaim-toolchain behavior; called out prod vs CI = separate EIPs.

Verified: test env config trio resolves; setup-cloud bash -n clean.

* docs(CLAUDE): #201 codify env-file + provisioner discipline for new data classes

This session's two test-mode bugs were systemic, not one-offs — fold them into the
#90 isolation section's data-class checklist so the next data-class-adder can't repeat:
1. a new data class MUST be added to BOTH operator-workstation.env AND .test.env
   (.test.env is not auto-derived; a prod-only key breaks the whole --ci path).
2. setup-cloud.sh delegation MUST pass ENV_FILE to provision/apply helpers (they
   re-source prod env + overwrite inherited $BUCKET, so --ci would hit prod buckets).
Includes the verify step (setup-cloud.sh --ci --dry-run must name -test resources).

* fix(harness): stage-3 Totals line renders colors (escapes in format string, not %s args)

The Totals summary printed literal \033[1;32m… because the C_* color vars (literal
"\033[…" strings) were passed as printf %s ARGS — printf only interprets \033 in
the FORMAT string, not in args. Moved the colors into the format string, matching
the ${C_*}-in-format pattern used everywhere else. TTY-gated defs unchanged, so
non-TTY/CI runs stay plain. Verified via cat -v (^[ = real ESC); bash -n clean.

* feat(daemon,worker): #201 Phases 4-5 — Config taxonomy memory list + lazy detail + codex hardening

Phase 4 (daemon): read/write the memory-types taxonomy via the Config data
class (--config-url/--config-role-arn); GET /v1/master/memory returns
categories from the taxonomy (no decrypt, cache fallback); new lazy
GET /v1/master/memory/entry?ns=&key=; plant writes per-namespace JSON arrays.
CLI hook memory-inject renders the array (single-body still injects). harness
memory-plant-demo + web-parity write/pass the new shape.

Phase 5 (frontend): apps/parent-control lists categories, decrypts a
namespace's entries on demand; plant re-fetches categories.

Codex adversarial-review hardening:
- finding 1 (data loss): plant is now a read-modify-write merge under a
  plant_lock (durable blob preserved; abort-on-read-error, never overwrite).
- finding 2 (silent failure): memory+config workers return 404 on NoSuchKey;
  list 502s on a configured-but-broken Config; plant returns taxonomy_status.

Workers changed → requires a setup-broker-host.sh redeploy for the 404 behavior.

* fix(ci): #201 stage-3 config steps — emit config env keys + tolerate config-role-missing

harness-e2e crashed at stage-3 step 19 with `CONFIG_ROLE_ARN: unbound variable`:
the CI env-materializer (harness-ci.yml) never emitted the config data-class keys
Phase 3 added to the stage-3 demo, and the demo runs under `set -u`.

- harness-ci.yml: materialize CONFIG_BUCKET / CONFIG_ROLE_ARN /
  AGENTKEYS_WORKER_CONFIG_URL (derived -test values, no new secret); allow the
  config-role-missing skip (operator one-shot, like scope-not-set) so step 19
  skips cleanly until the test config bucket/role are provisioned. Steps 20-21
  (config cap-mismatch) still run against the deployed config worker.
- v2-stage3-demo.sh: default the config vars to empty after sourcing the env
  file → degrade via prereq_missing instead of an unbound-variable abort.
- CLAUDE.md: fold the materializer into the env-file discipline (3rd place a
  new data class's keys must land).

* docs(#207): onboarding/classifier design spec + policy/scope/namespace wiki

Add the product/onboarding view of the classifier design (#178) on top of the landed Config substrate (#201): the two config-init entry points (default preset + NL->COMPILE), connect-time classifier auto-distribution of cred + memory scopes (one pattern, two axes), the four security invariants, and the resolved decisions tracked in #207 (telemetry split to #208).

- docs/plan/web-flow/onboarding-classifier-distribution.md (new spec)
- docs/wiki/policy-scope-namespace.md (new terminology reference, lint-clean)
- docs/arch.md section 5 canonical-names row (policy/scope/namespace/category/service)
- docs/plan/classifier-service.md cross-links

* ci(harness): allow config-worker-unreachable skip on the test env (#201)

stage-3 step 21 hits the config worker HTTPS endpoint (config-test.<zone>), whose cert can't issue until the config-test DNS record is provisioned by the operator one-shot (setup-cloud.sh --ci) — the SAME one-shot already tolerated via config-role-missing. Add config-worker-unreachable to the stage-3 allow-skip so CI skips step 21 cleanly until the test config infra exists; step 20 + the agentkeys-worker-config unit tests still cover the config cap-data-class-mismatch. harness/CLAUDE.md already documents steps 19-21 as 'skip until config infra is provisioned/deployed'. Drop the allowance once config-test is provisioned.
hanwencheng added a commit that referenced this pull request Jun 6, 2026
… combine, not just resolve

#205 (issue #201) landed a THIRD data class (Config): /v1/cap/config-{store,fetch}
+ an agentkeys-worker-config worker + a hand-rolled daemon config/per-ns-memory
chain. #204 (#203) made agentkeys-backend-client the ONE owner of the broker/worker
protocol. Rather than let the two coexist as parallel hand-rolled vs crate-owned
chains, this merge folds #205's new surface INTO the #203 single-owner model.

Conflicts resolved (2 files):
- ui_bridge.rs: adopt #205's per-namespace storage model wholesale (memory_put_ns_real
  / memory_get_ns_real / RMW-under-plant-lock / real_config_ctx) — my per-entry
  memory_put_real + real_memory_client are SUPERSEDED, dropped. Kept my route consts
  (MASTER_MEMORY_{,PLANT_}ROUTE) + the plant-contract unit test, and #205's new
  /v1/master/memory/entry route. Swapped #205's inline 0x-normalize in the shared
  resolve_session_coords for the crate's normalize_omni_0x.
- memory-plant-demo.sh: keep #205's per-ns JSON-array blob + my @backend-fixture
  annotation.

Combine (#203 applied to #205's surface):
- crate: CapMintOp gains ConfigStore/ConfigFetch (6 cap endpoints now); add
  ConfigPutBody/ConfigGetBody + fixtures (regenerated, now 6).
- daemon mint_master_cap → BackendClient::cap_mint (the cap-mint body — the #200
  drift locus — is now the crate's BrokerCapRequest for memory AND config; one
  function covers all 4 routes). Worker put/get bodies (memory + config) build from
  the crate's MemoryPutBody/MemoryGetBody/ConfigPutBody/ConfigGetBody types; the raw
  POST stays in the daemon to reuse the once-minted STS creds across namespaces.
  Re-added agentkeys-provisioner to the daemon (still used for that STS mint).
- gate: config_put/config_get fixtures are pass-1-annotatable but EXCLUDED from
  pass-2 auto-detect (key-set-identical to cred bodies → would false-positive);
  documented in the gate + the fixtures README. #205's bash bodies (4-key ttl-omitted
  cap + ambiguous cred/config worker bodies) don't trip pass-2.
- docs: arch.md tree gains agentkeys-worker-config + updated backend-client note;
  root CLAUDE.md #203 rule updated for the 6 endpoints + config body types.

Verified: cargo build + clippy -D warnings + cargo test --workspace all clean (0
failures; plant-contract + config frozen tests pass); backend + web-api drift gates
+ fixture --check pass under LC_ALL=C.UTF-8; bash -n clean on all touched scripts.
hanwencheng added a commit that referenced this pull request Jun 6, 2026
…6 (web-parity)

harness-ci.yml ran v2-stage{1,2,3}-demo.sh in isolation — it predated the #200
v2-demo restructure and never picked up phase 4 (memory-plant) or phase 6
(web-parity). Phase 6 is the ONLY runtime proof of the daemon's web endpoint
(POST /v1/master/memory/plant → cap-mint → STS → worker → S3, the parent-control
app's path); stage 3 only exercises the CLI/curl path. The #203
check-web-api-drift.sh gate covers its SHAPE at compile/fixture time, but nothing
covered its runtime reachability in CI.

Switch the harness-e2e job to the whole orchestrator: `v2-demo.sh --ci` → phases
1-4 + 6. Phase 5/wire auto-skips — the §10.2 agent needs the aiosandbox, which CI
doesn't have, so --ci sets --wire none (the one phase CI genuinely can't run).
Running phases in sequence also means phase 1 registers the master that phase 6
reuses.

Enabler: v2-stage1-demo.sh now auto-skips deploy/email/provision under --ci/$CI
(CI runs against pre-provisioned infra — contracts pinned in TEST_*_HEIMA secrets,
identity via wallet_sig, vault/memory buckets+roles an operator one-shot the CI
role can't recreate). Mirrors stage-1's existing auto-WEBAUTHN-off + stage-2's
auto-stub under --ci, so `v2-demo.sh --ci` drives stage 1 without re-passing the
three skip flags. The build step now builds what v2-demo's preflight expects
(cli + daemon + mcp-server; mock-server is mock-mode-only and unused in real CI).

Docs: harness-ci.yml header + harness/CLAUDE.md CI-role note + the operator
runbook's On-CI semantics. (The runbook already documented `v2-demo.sh --ci` as
the CI front door — this makes the workflow match it.)

NOTE: the harness-e2e job is secret-gated (TEST_OIDC_AWS_ROLE_ARN) and can't run
locally — validated by YAML lint + bash -n + flag-threading review + the drift
gates; needs a CI run with the test secrets to confirm end-to-end.
hanwencheng added a commit that referenced this pull request Jun 6, 2026
…rker chain (#204)

* refactor: #203 agentkeys-backend-client — ONE owner for the broker/worker chain

The broker/worker HTTP chain was hand-coded in three places (MCP HttpBackend,
daemon ui_bridge, harness bash), the structural cause of the #200 drift bugs
(evm_address vs {address,chain_id}, bare-vs-0x omni, per-namespace field
shapes). Collapse it behind one crate so drift is a COMPILE error (Rust callers
share the types) or a FIXTURE mismatch (the harness gate), not a runtime 4xx.

New crate agentkeys-backend-client (the dual of broker-server / worker-*):
- protocol.rs: every cap-mint / worker / audit wire shape, the memory:<ns>
  service builder, and the 0x-omni normalizer (the daemon's old inline bug site)
- client.rs: BackendClient — cap-mint (4 data-class endpoints) -> STS relay ->
  worker put/get -> audit append (the reference impl lifted out of HttpBackend)
- fixtures.rs + dump-protocol-fixtures bin: canonical fixtures serialized from
  the serde types + frozen key-set pins

Collapse the duplicates (net -355 LOC in existing files):
- MCP HttpBackend -> thin delegate over BackendClient; backend wire-shape
  submodules (broker/memory/audit) deleted, re-exported from the crate so the
  Backend trait + InMemoryBackend + tools keep their crate::backend::* paths
- daemon memory_put_real / real_memory_ctx -> call the shared client (kills the
  duplicate cap-mint body + the inline 0x-normalize where the bugs lived)

Enforce (fold-systemic-fixes-into-enforcement):
- scripts/check-backend-fixture-drift.sh: diffs every # @backend-fixture-
  annotated bash body against the crate-emitted fixtures (catches add/rename/drop)
- harness-ci.yml rust-checks runs the fixture --check + the bash gate on every PR
  touching crates/**, harness/**, scripts/**
- root CLAUDE.md + harness/CLAUDE.md "broker/worker shapes have ONE owner" rule;
  arch.md component inventory updated

* refactor: #203 tier-2 — close phase-6's frontend false-green (the #206 parity ladder)

#204 made the broker/worker chain tier-3 (compile-enforced). The adjacent
blind spot the #206 ladder names is the daemon's web API: the route
/v1/master/memory/plant + the ApiMemoryEntry body are hand-copied in 3 places —
the daemon (Rust source of truth), the React frontend daemon.ts, and the harness
web-parity-demo.sh — agreeing only by manual coincidence. A daemon.ts route/shape
change left phase 6 green on the old path (false-green).

Pin all three to one serde source of truth (rung 2 of the ladder):
- daemon: MASTER_MEMORY_{,PLANT_}ROUTE consts (used by the router) + a ui_bridge
  unit test (master_memory_plant_contract_matches_fixture) pinning ApiMemoryEntry's
  keys + the route to harness/fixtures/web-api/master_memory_plant.json
- gate scripts/check-web-api-drift.sh diffs the two NON-Rust consumers (daemon.ts +
  web-parity-demo.sh, both carrying a `@web-fixture: master_memory_plant` annotation)
  against that fixture — route + entry key-set. Wired into harness-ci rust-checks.
- a daemon.ts route rename or entry field add/rename/drop is now CI-red, not a
  stale green (negative-tested both halves).

Docs: update the #206 ladder section in harness/CLAUDE.md (false-green now CLOSED;
plant contract is at rung 2; rung-3 endgame = agentkeys-web-core wasm so daemon.ts
stops hand-building the body); add the web-api gate to the root CLAUDE.md #203 rule.

* fix(harness-ci): brace $SCAN_DIR so the fixture gate survives set -u in a UTF-8 locale

scripts/check-backend-fixture-drift.sh interpolated `$SCAN_DIR…` — the variable
immediately followed by a Unicode ellipsis (U+2026, E2 80 A6). Under `set -u` in a
UTF-8 locale (C.UTF-8 / en_US.UTF-8 — what GitHub ubuntu-latest uses), bash's
multibyte identifier scan absorbs the ellipsis into the name, reads `SCAN_DIR…` as
an unbound variable, and aborts before checking any fixture. The new harness-ci
`rust-checks` step (`bash scripts/check-backend-fixture-drift.sh`) would then fail
on EVERY PR regardless of protocol correctness, and the drift protection never ran.

Reproduced locally: `set -u; V=/tmp; echo "$V…"` exits 1 (`V: unbound variable`)
under LC_ALL=C.UTF-8/en_US.UTF-8 but exits 0 under LC_ALL=C; the braced form
`${V}...` exits 0 under all three. Both gates now pass under LC_ALL=C.UTF-8 +
en_US.UTF-8.

Fix: brace the var (`${SCAN_DIR}`, the CLAUDE.md interpolation-defense convention)
and use ASCII `...` so no following byte can extend the name. Also switched the one
other executable ellipsis log line in check-web-api-drift.sh to ASCII for the same
robustness. Repo-wide scan confirms no other `$VAR<multibyte>` adjacency in
scripts/ or harness/. (Codex adversarial-review finding.)

* fix(harness-ci): tighten both drift gates — call-site route check + unannotated-canonical guard

Two Codex adversarial-review findings, both a residual false-green:

1. Route check passed on stale literals (check-web-api-drift.sh). The web-api
   gate `grep`ed the whole consumer file for the canonical route, so the route
   appearing in a step label / comment satisfied it even if the actual POST URL
   changed — the exact false-green the gate exists to close. Now assert the CALL
   SITE: the route must appear immediately followed by a closing quote (it
   terminates a URL/string literal) within a few lines of a `curl`/`-X POST`
   (bash) or `postJson`/`fetch` (TS) call. A stale label (route followed by a
   space/arrow) no longer satisfies it; a drifted prefix like `…/plantX"` is
   rejected because the char after `plant` is `X`, not a quote. Verified: changing
   the real curl URL while leaving the step label stale now fails.

2. Fixture gate missed an unannotated canonical body (memory-plant-demo.sh:154).
   The `/v1/memory/get` read-back hand-rolled `{cap, namespace}` with no
   `@backend-fixture` annotation, so pass 1 (annotated-only) never gated it.
   Fix both ways: (a) annotate that body; (b) add pass 2 to
   check-backend-fixture-drift.sh — scan EVERY single-quoted jq object literal
   and fail any whose key-set EXACTLY matches a canonical fixture but lacks an
   annotation. Exact-match is false-positive-free: the v2-stage3 cred bodies
   (`{cap, plaintext_b64}`, `{cap}`) and the ttl-omitted 4-key cap variant
   (broker `CapRequest.ttl_seconds` is `#[serde(default)]`) match no canonical set
   and are left alone. Verified: removing the annotation now fails pass 2;
   re-adding passes; no other unannotated canonical bodies exist in the harness.

Both gates pass under LC_ALL=C.UTF-8 + en_US.UTF-8; bash -n clean. (Codex
adversarial-review findings.)

* ci(harness): run the whole v2-demo (--ci) so harness-CI covers phase 6 (web-parity)

harness-ci.yml ran v2-stage{1,2,3}-demo.sh in isolation — it predated the #200
v2-demo restructure and never picked up phase 4 (memory-plant) or phase 6
(web-parity). Phase 6 is the ONLY runtime proof of the daemon's web endpoint
(POST /v1/master/memory/plant → cap-mint → STS → worker → S3, the parent-control
app's path); stage 3 only exercises the CLI/curl path. The #203
check-web-api-drift.sh gate covers its SHAPE at compile/fixture time, but nothing
covered its runtime reachability in CI.

Switch the harness-e2e job to the whole orchestrator: `v2-demo.sh --ci` → phases
1-4 + 6. Phase 5/wire auto-skips — the §10.2 agent needs the aiosandbox, which CI
doesn't have, so --ci sets --wire none (the one phase CI genuinely can't run).
Running phases in sequence also means phase 1 registers the master that phase 6
reuses.

Enabler: v2-stage1-demo.sh now auto-skips deploy/email/provision under --ci/$CI
(CI runs against pre-provisioned infra — contracts pinned in TEST_*_HEIMA secrets,
identity via wallet_sig, vault/memory buckets+roles an operator one-shot the CI
role can't recreate). Mirrors stage-1's existing auto-WEBAUTHN-off + stage-2's
auto-stub under --ci, so `v2-demo.sh --ci` drives stage 1 without re-passing the
three skip flags. The build step now builds what v2-demo's preflight expects
(cli + daemon + mcp-server; mock-server is mock-mode-only and unused in real CI).

Docs: harness-ci.yml header + harness/CLAUDE.md CI-role note + the operator
runbook's On-CI semantics. (The runbook already documented `v2-demo.sh --ci` as
the CI front door — this makes the workflow match it.)

NOTE: the harness-e2e job is secret-gated (TEST_OIDC_AWS_ROLE_ARN) and can't run
locally — validated by YAML lint + bash -n + flag-threading review + the drift
gates; needs a CI run with the test secrets to confirm end-to-end.

* fix(harness): v2-demo preflight must put target/release on PATH (CI phase-1 fix)

The harness-CI switch to `v2-demo.sh --ci` failed at phase 1:
`v2-stage1-demo.sh: line 360: agentkeys: command not found`. The stages call a
BARE `agentkeys` (resolved from PATH). In the old per-stage CI, each stage ran its
own build step (no --skip-build), which installs agentkeys onto PATH via
install-agentkeys-cli.sh. Under v2-demo the preflight builds target/release ONCE
and tells the stages to --skip-build — so they skip the install, and the preflight
never exposed the built binary on PATH. CI has no globally-installed agentkeys, so
the bare call died, cascading: phase 1 died at step 5 (before its register), so the
master was never registered → phase 2 register_first_master also couldn't find
agentkeys-cli → heima-worker-smoke failed.

Fix: the preflight now `export PATH="$PROJECT_ROOT/target/release:$PATH"` right
after the build — so every phase subprocess resolves the just-built agentkeys /
agentkeys-daemon (prepended, so it wins over any stale global install). This is the
missing piece of the preflight's "build once, phases reuse" contract; it helps
operators too (they get the build they just made, not a stale install). Verified
locally: a bare `agentkeys chain show heima` resolves + runs under the exported
PATH (the exact line-360 pattern).

Also (cleanup completeness): the harness-e2e S3 cleanup now also wipes the CONFIG
bucket's bots/<omni>/config/ (phase 6 writes the #201 memory-taxonomy there when
config infra is present). Guarded by [ -n "$CONFIG_BUCKET" ], so it's a no-op
until TEST_CONFIG_BUCKET is set. Memory + creds (vault/memory buckets) were already
wiped; this closes the config-class gap.
hanwencheng added a commit that referenced this pull request Jun 7, 2026
Ties the two existing halves into one ready-to-sign PackedUserOperation:
- intent: agentkeys_core::erc4337::accept_batch_calldata (the atomic
  executeBatch([registerAgentDevice, setScope]), P.2+P.3)
- sponsorship: the broker EIP-191 co-signs the VerifyingPaymaster getHash
  (J1-gated Sybil gate = gas-free), via crate::sponsor (#200 Stage A).

New crates/agentkeys-broker-server/src/sponsored_accept.rs:
- AcceptUserOpParams — every chain-derived value (nonce/gas/fees/validity/addrs)
  is an explicit input (nothing hardcoded; caller reads them on-chain).
- assemble_accept_userop(params, broker_sk) -> AssembledAcceptUserOp { user_op,
  user_op_hash, paymaster_get_hash }. Sets paymasterAndData[20:52] (the gas word)
  provisionally so paymaster_get_hash commits the limits the broker approves, then
  rebuilds paymasterAndData with the real co-sign appended; computes the userOpHash
  the master K11 signs. Pure (broker key only, no chain I/O).

Broker-side because the paymaster co-sign needs the broker key; the daemon will
call this via an endpoint and just K11-sign the returned userOpHash (the #200
division of labour). 3 unit tests: callData==accept batch + sender==master +
empty account sig + deterministic hash; paymasterAndData layout + broker co-sign
recovers to the broker EOA; grant change => userOpHash change. cargo test + clippy green.

Slice 2 of #225. Next: the broker HTTP endpoint wrapping this + the daemon call +
the Stage-B handleOps submit (cast-based, mirrors the E8 proof). Refs #225.
hanwencheng added a commit that referenced this pull request Jun 8, 2026
…point-of-compromise (#223)

* feat: #76 cap-mint K10 proof-of-possession — close the broker single-point-of-compromise

Every cap-mint now carries a K10 device-key signature the worker re-verifies
INDEPENDENTLY of the broker, so a compromised broker (which holds no K10 private
key) cannot mint a usable cap. Closes the §22b.4 stage-1 gap, where the worker
re-checked only the broker's own broker_sig + on-chain device *registration*
(device_key_hash is a public identifier), never possession.

- core: device_crypto::cap_pop_payload (domain-separated, request-bound) +
  cap_pop_now/cap_pop_sig + load_device_key_from_env
- broker: handlers/cap.rs::verify_cap_pop rejects forged/missing/stale client_sig
  (cap_pop_invalid 4xx); §22b.4 shortcut removed
- workers (cred/memory/config/classify): verify::check_client_pop, fail-closed,
  gated by AGENTKEYS_WORKER_REQUIRE_CAP_POP (default enforce, mirrors REQUIRE_STS)
- clients: BackendClient::with_device_key signs the PoP inside cap_mint (MCP,
  daemon ui-bridge, proxy); BrokerCapRequest fields + #203 fixtures regenerated
- master K10/K11 split: harness/scripts/heima-register-master-k10.sh registers the
  master's secp256k1 K10 as a CAP_MINT device (registerAdditionalMasterDevice,
  reusing the #200/#164 K11-assertion machinery), wired into setup-heima step 15
- docs: arch.md §22b.4 resolved + headline guarantee; CLAUDE.md isolation table

Agent path verified: 50 Rust test suites green, clippy clean, backend-fixture gate
green. NEEDS-LIVE-VERIFICATION (no chain / Touch ID here): the on-chain master-K10
registration (cast/ABI + EOA-vs-#164-UserOp msg.sender) and cast EIP-191 matching
device_crypto::eip191_sign in the 2 master-path harness demos.

* style: cargo fmt the #76 K10 PoP code (CI fmt --check)

* fix: #76 make cap-mint K10 PoP optional + graceful (staged rollout)

The harness on test infra caught that hard-requiring the K10 cap-PoP broke the
master-self path: the master registers device_key_hash=keccak(operator_omni)
(the #164 passkey account) and has no secp256k1 K10 registered yet, so master
cap-mints (phase 4 memory-plant, phase 6 web-parity) failed 'master K10 not found'.

Make the PoP optional + verify-when-present (the correct non-breaking staged rollout):
- protocol/broker/worker: client_sig/nonce/ts are Option; a supplied PoP is always
  validated (broker verify_cap_pop + worker), a MISSING PoP is rejected ONLY under
  AGENTKEYS_WORKER_REQUIRE_CAP_POP=1 (default OFF). New verify::enforce_client_pop
  centralizes the gate across the 4 workers.
- clients (BackendClient/ui_bridge/proxy): sign when a K10 is available, else mint
  with no PoP + the caller's device_key_hash — no hard-fail.
- harness master demos: revert to no-PoP bodies (master mints without PoP until its
  K10 is registered); fixture cap_mint_request back to the minimal no-PoP key-set.
- docs (arch §22b.4 + headline, CLAUDE.md): enforcement is a staged flag-flip after
  every actor's K10 (incl. the master's) is registered — that's when the SPOF closes.

The agent path still carries a verified PoP (agents register keccak(K10 addr)).
fmt + clippy + full test suite + fixture gate green locally.

* fix(harness): tolerate email-inbox 5xx (502/503), not just 500, in worker-smoke

The funded harness run cleared the gas failures; the only remaining red was
phase 1 step 15 (worker-smoke email-inbox) returning HTTP 502 — the SAME known
's3:ListBucket IAM not wired on the broker EC2' follow-up the soft-warn already
tolerates as 500, but surfacing via nginx (502) when the worker errors on
ListObjects. The toleration only matched 500, so the 502 variant fell through to
die. Broaden to the 5xx class (500|502|503). Not a #76/code issue — the email
worker /healthz passes; inbox LIST IAM is a separate deploy follow-up.

All code gates + harness phases 2-6 (incl. the #76 cap-PoP path: phase 3 negatives,
phase 4 plant, phase 6 web-parity) already pass on the funded run.
hanwencheng added a commit that referenced this pull request Jun 9, 2026
….3) (#227)

* feat: #216 agent cred-fetch — CLI consumer + real e2e (VERIFIED against live infra)

The agent-facing consumer of the #216 cred-fetch primitive, verified end-to-end
against the LIVE broker + cred worker:

- agentkeys-cli: `agentkeys cred fetch <service>` (cred_admin.rs) — mints a
  master-self/agent CredFetch cap → BackendClient.cred_fetch → STS → cred worker
  → decrypt → prints the plaintext. Adds the agentkeys-backend-client dep (the
  #204 one-owner path; no re-typed wire shapes).
- harness/cred-fetch-demo.sh — the real e2e: a master VAULTS a probe cred via the
  daemon (web path), then the agent FETCHES it via the CLI (agent path), asserting
  the EXACT secret round-trips through cap-mint → STS → cred worker → S3 → decrypt.
  Idempotent (fixed `cred-e2e-probe`), --ci-tolerant, real-only. Contract-compliant
  (STEP_TOTAL=4, ok/skip/fail, EXIT-trap daemon cleanup).
- keep-docs-in-sync: harness/CLAUDE.md orchestrator table + operator-runbook-harness.md.

VERIFIED LIVE (this run): master vaulted via daemon (HTTP 200), agent
`cred fetch` returned the EXACT key (len matched) — broker.litentry.org +
cred.litentry.org. #216's cred half is proven, not just compiled.

Remaining #216: the Hermes wire (phase1-wire Phase 4.0) — plant the fetched key
into Hermes instead of $OPENROUTER_API_KEY (the full sandbox surprise).

* feat: #216 cred-wire-demo.sh — the FULL agent-side wire e2e (VERIFIED live, real LLM)

Carries the #216 cred-fetch through the Hermes wire — the complete agent-side
guarantee, proven end-to-end against the LIVE broker + cred worker + aiosandbox:

  master VAULTS the LLM key  (daemon: cap-mint cred-store → STS → cred worker → S3)
    → agent CRED-FETCHES it  (agentkeys cred fetch: cap-mint cred-fetch → STS → decrypt)
    → plant into Hermes      (~/.hermes/.env + hermes config set model.*) IN THE SANDBOX
    → Hermes RUNS on the vault key (real LLM smoke) — NO OPENROUTER_API_KEY in the agent env

harness/cred-wire-demo.sh (STEP_TOTAL=6, contract-compliant, headless): asserts
the key Hermes uses == the master-vaulted key (sha), and that it arrived via the
vault fetch, not an ambient env var (the sandbox shell has no OPENROUTER_API_KEY;
the .env value is the cred-fetch result). The durable, no-Touch-ID complement to
phase1-wire-demo.sh Phase 4.0b — same wire result without the interactive gates.
Routes through the shared agentkeys-backend-client (#204).

VERIFIED LIVE (this run, real OpenRouter key):
  step 4  ok agent fetched the vaulted key from the vault (len=73, sha fddff3ff…) — no env read
  step 5  ok planted the vault-fetched key into ~/.hermes/.env + hermes config
  step 6  ok 6.1 vault-sourced — the key Hermes will use == the master-vaulted key, NOT an env var
  step 6  ok 6.2 llm smoke — Hermes answered using the VAULT-FETCHED key: "OK"
Exit 0. A REAL deepseek-v4-flash call via OpenRouter answered "OK" on the
vault-fetched key — #216's acceptance ("the agent runs on MY authorized key, not
the operator's env") proven with real data.

Idempotent (FIXED openrouter service; the .env key-line is rewritten not appended);
daemon killed on exit; --ci-tolerant. keep-docs-in-sync: harness/CLAUDE.md +
docs/operator-runbook-harness.md.

* feat: #216 phase1-wire Phase 4.0b — plant the VAULT-fetched key (env → dev fallback)

Replaces the operator-env-key write (#216's named target: phase1-wire-demo.sh:1072)
with the vault path: Phase 4.0b now fetches the agent's LLM key from the master's
VAULT via `agentkeys cred fetch cred:<service>` and plants THAT into the sandbox
Hermes — the $OPENROUTER_API_KEY/$LLM_API_KEY env becomes a clearly-labelled
DEV-ONLY fallback.

- Phase 4.0b resolves WIRE_KEY VAULT-FIRST (the agent-identity cred-fetch: operator
  session authorizes, actor=agent device — mirrors the memory cap-mint identity
  model), env-fallback only when the vault is unavailable. Backward-compatible: with
  no vaulted key / no cred scope the fetch fails and it degrades to the env key
  exactly as before, so the change is fallback-safe.
- SEED_SCOPE_SERVICES also grants the agent its cred scope (bare `$SERVICE` — the
  cred-fetch cap-mint hashes the bare service, unlike memory's `memory:<ns>`) so the
  P.3 pairing grant authorizes the vault fetch.
- Honest labelling throughout: the 0.6 step, the header, and the top overview now
  state the env key is the dev fallback and the vault is primary; the 4.0 ok line
  prints which source the planted key came from.

The full vault chain (master vaults → agent cred-fetches → plant → Hermes runs on
it, real LLM smoke) is proven headless + live by harness/cred-wire-demo.sh (this
PR). The interactive agent-identity path additionally needs the operator's Touch ID
cred-scope grant (P.3) + a seeded vault — until then Phase 4.0b labels + uses the
dev fallback.

* feat: #216 `agentkeys cred store` — symmetric store half + #204 daemon fix (verified live)

Completes the CLI cred surface with the store half of `cred fetch`, and folds the
daemon's hand-rolled cred-store body into the crate (closing a #204 drift gap):

- agentkeys-backend-client: `CredStoreBody`/`CredStoreResp`/`CredStoreInput`/
  `CredStoreResult` (mirror the CredFetch types) + `BackendClient::cred_store`
  (cap-mint CredStore → per-actor STS under the VAULT role → cred worker
  `/v1/cred/store` → encrypt + S3 PUT). Exported from the crate.
- agentkeys-daemon: `store_master_credential_inner` now builds the worker body from
  the crate-owned `CredStoreBody` instead of an inline `serde_json::json!({...})`
  (#204 — "broker/worker request shapes have ONE owner"; a drifted field is now a
  compile error, matching the memory-put path).
- agentkeys-cli: `agentkeys cred store <service> --secret|--secret-env` (master-self
  by default). `--secret-env NAME` keeps the plaintext off argv / out of the shell
  history + process list. Prints the worker S3 key.

VERIFIED LIVE (CLI-only store→fetch round-trip, master-self):
  stored `cred-store-probe` → bots/941…/credentials/cred-store-probe.enc
  ✅ CLI store→fetch ROUND-TRIP PASS — agentkeys cred store works end-to-end

Scope note: this is the master-self vault primitive. The master provisioning a key
INTO the agent's S3 prefix (so the agent fetches with actor=agent) needs dual
bearers (operator session for cap-mint + agent session for the STS PrincipalTag)
and is #214's authorization-side job — deliberately out of #216 scope.

clippy -D warnings clean; cargo check green.

* docs: #216 make operator-runbook-wire.md the single source of truth (web app + CLI, fresh start)

Restructures the wire runbook from a CLI/sandbox + memory-only "run the demo" doc
into the single fresh-start guide for testing the WHOLE wire — both the #216
vault-fetched LLM key and the permissioned memory — two ways:

- New top: the two guarantees, a two-paths table (web app vs CLI, same agent side),
  the fastest test (`harness/cred-wire-demo.sh`), and a fresh-start checklist
  (3 setup scripts + sandbox + OpenRouter key + master identity).
- Path A — Web app: `bash dev.sh` → onboard → vault the key (credentials page) →
  pair+authorize (pairing page, Touch ID). Honest "wired vs pending" note: the web
  vault + #214 pairing are real/on-chain today; the agent-identity vault-fetch needs
  #214's dual-bearer master-provisioning (not wired yet), so the master-self
  cred-wire-demo is the end-to-end proof.
- Path B — CLI: the existing phase1-wire-demo walkthrough, reframed.
- LLM-key gate now documents Phase 4.0b vault-first/env-fallback; "Verifying it
  worked" splits into the two deterministic checks; +3 web/cred troubleshooting rows;
  Appendix B gains the `cred store`/`cred fetch` primitives; cross-refs add the new
  demos + #216/#214 + dev.sh.

keep-docs-in-sync: folds back the cred-wire-demo + cred-store + Phase 4.0b changes
from this PR into the operator runbook.

* docs: #216 fix Path A — the web app doesn't provision the agent device

Caught in review: Path A had the agent run in the sandbox (agentkeys-daemon
--request-pairing → cred fetch → wire hermes) but never said how the compiled
agentkeys / agentkeys-daemon / agentkeys-mcp-server binaries get INTO the sandbox.
They can't run there unless cross-built for the sandbox's Linux arch and uploaded
(the sandbox is aarch64/x86 Linux, not the operator's Mac) — which is what Path B /
phase1-wire-demo.sh Phase 1 does (target/sandbox-linux cross-build → sbx_put).

Rewrote Path A to be honest:
- The web app is ONLY the master's console; it does not provision the agent device.
- A. Vault the LLM key — fully standalone (no sandbox).
- B. Pair — needs the agent binaries in the sandbox first; and phase1-wire's Phase 1
  bundles the cross-build/upload WITH the CLI pairing (Phase P lives inside Phase 1),
  so there's no clean "binaries only" command and no one-command web-pairing flow yet
  (drive the web claim by hand: upload binaries, open a request, claim in the UI).
- C. End-to-end is the headless cred-wire-demo.sh / Path B.
Also corrected my own first attempt, which suggested `--skip-2..5` to "stage only the
sandbox" — that still runs Phase 1 and therefore CLI-pairs the agent.

* docs+harness: #216 make wire runbook Path A / Path B fully independent + add sandbox-build-push.sh

Per review: the runbook treated Path A as leaning on Path B's harness for the agent
side. Now each path is a self-contained quick-start.

- NEW harness/sandbox-build-push.sh — Path A's standalone "compile agentkeys + push to
  the sandbox" command. Cross-builds the 3 binaries (agentkeys / -mcp-server / -daemon)
  for the sandbox's aarch64-Linux arch in the SAME cached arm64 builder image + cargo
  volumes phase1-wire-demo uses (warm tree re-pushes in seconds), uploads them to
  ~/.local/bin. Build + push ONLY — never pairs/wires. Re-run after any local change so
  the in-sandbox agent runs current source. VERIFIED live: pushed to the sandbox, and
  `agentkeys cred --help` there confirms the current #216 source.
- operator-runbook-wire.md restructured: "Two independent paths — pick one" with BRIEF
  quick-starts for each (Path A = sandbox-build-push.sh + dev.sh + 3 UI actions; Path B =
  one phase1-wire-demo command) + a "neither path" headless check (cred-wire-demo). Path A
  details now use sandbox-build-push.sh (dropped the phase1-wire dependence + the
  now-moot "harness bundles pairing" caveat); kept the honest #214 wired-vs-pending note.
- keep-docs-in-sync: harness/CLAUDE.md inventory + operator-runbook-harness.md.

* docs: #216 fix Path A pairing command — --request-pairing requires --broker-url

Operator hit `Error: --broker-url (or AGENTKEYS_BROKER_URL) required for
--request-pairing` running the runbook command in the sandbox — my Path A command
dropped the required flag. Verified the corrected invocation in the live sandbox
(produces a pairing_code). Folded the complete, correct flow into Path A:

  1. sandbox: agentkeys-daemon --request-pairing --broker-url https://broker.litentry.org
     → prints pairing_code + a state_file (the request_id lives in the file, not stdout)
  2. web UI: claim the pairing_code (Touch ID)
  3. sandbox: agentkeys-daemon --retrieve-pairing --request-id <from state file> --broker-url …

Matches phase1-wire-demo.sh Phase P.0/P.1b exactly. Fixed both the quick-start and the
Path A — details command.

* feat: #216 default the agent pairing broker to prod (no --broker-url needed)

`agentkeys-daemon --request-pairing` / `--retrieve-pairing` required --broker-url
(or AGENTKEYS_BROKER_URL) and errored without it — friction for the Path-A operator
running them in the sandbox. These commands ALWAYS need a broker, so default it:

- main.rs: new `const DEFAULT_PAIRING_BROKER_URL = "https://broker.litentry.org"`;
  run_request_pairing + run_retrieve_pairing now `unwrap_or_else(default)` instead of
  erroring. `--broker-url` / `AGENTKEYS_BROKER_URL` still override (e.g. a test broker).
  Deliberately NOT a global arg default — `--ui-bridge`'s unset broker_url keeps its
  "fall back to pre-sourced AWS creds" meaning (the §191 pre-Stage-7 path).

VERIFIED live: cross-built + pushed the daemon to the sandbox; `agentkeys-daemon
--request-pairing` (no flag) now defaults to prod + opens a §10.2 request (code
9ZpC8nwu…) — the "--broker-url required" error is gone.

Runbook (Path A quick-start + details) simplified to drop the flag; notes the prod
default + the override. clippy -D warnings clean; daemon tests green.

* fix: #214 web pairing register 502 — daemon couldn't find heima-agent-create.sh

`accept pairing · Touch ID` POSTed /v1/agent/pairing/register and got 502. Root
cause: register_pairing derived the agent-register script as a SIBLING of
--register-master-script, but the two are NOT co-located — dev.sh's master register
is harness/scripts/heima-register-first-master.sh while heima-agent-create.sh lives
in <repo>/scripts/. The sibling path (harness/scripts/heima-agent-create.sh) doesn't
exist, so `bash <missing>` exited non-zero → register_agent_device errored → 502.

Fix: resolve heima-agent-create.sh from candidates — the sibling (co-located case)
AND <repo>/scripts/ derived from the master script path — picking the first that
exists; fail with a clear SERVICE_UNAVAILABLE message if neither is found.

Verified: scripts/heima-agent-create.sh accepts exactly the args register_agent_device
passes (--label/--agent-address/--actor-omni/--device-key-hash/--pop-sig, from-pubkey
mode auto-detected), and a dry-run with the live agent details returns
{"ok":true,"skipped":"already-registered"} → register_agent_device → Ok(None) → 200.
The "no Touch ID" is expected (browser passkey UserOp is the E7-pending frontend item;
the register goes through the daemon script shell-out today). clippy -D warnings clean;
daemon tests green.

* style: rustfmt the merged ui_bridge.rs (register path-fix block)

* feat: #224 pairing-card cross-verification — show device_key_hash + full request_id (slice 1)

The master pairing card showed a truncated "PAIR-CODE" that was actually the
request_id (never the agent's one-time code), with no value the operator could
cross-check against the agent — a confused-deputy surface (#224). Slice 1 surfaces
the values that ARE on both sides today, with no broker change/deploy:

- daemon (pending_binding_to_request): map the broker's device_key_hash →
  `deviceKeyHash` (+ short); keep `id` (the full request_id). The agent's
  `--request-pairing` already prints device_key_hash + D_pub, so these are the
  cross-verifiable identity.
- agent (run_request_pairing): print device_key_hash on the human-facing line so the
  operator reads it off the agent to compare.
- frontend (PairingRequest type + pairing card): replace the misleading "pair-code"
  with **device key hash · verify on agent** + **D_pub · verify on agent** (full) +
  **request id** (full handle). Operator confirms the device matches before
  accept · Touch ID.
- test: pending_binding_maps_to_pairing_request asserts the full deviceKeyHash.

Deferred to slice 2 (needs a broker change + deploy): created_at/expires_at
timestamps on the card (the broker pending row has no timestamps today) and the
`--force` supersede-prior-requests behavior. clippy/fmt clean; daemon tests + frontend
typecheck green.

* ui: #224 relabel pairing card D_pub → 'device public address · verify on agent'

* ui: refresh paired-device list after accept so it shows without a manual reload

acceptPairing did registerPairing + refreshPairing but never re-fetched the
actor tree, so a freshly-registered agent only appeared in the device/permission
views after the operator reloaded the page. Re-fetch listActors after a
successful register (matches finishPairingCeremony), surfacing the paired device
immediately.

* feat: #225 E7 — ERC-4337 accept-batch callData builders (atomic P.2+P.3)

The agent-accept gate (#225 / #164 E7) lands the device binding (registerAgentDevice,
P.2) and the scope grant (setScope, P.3) in ONE P256Account.executeBatch UserOp —
one block, one K11 signature, atomic. This adds the pure callData encoders that the
batch needs (the genuinely new primitive); the sponsored-UserOp envelope is already
owned by the broker's sponsor.rs (#200 Stage A).

New crates/agentkeys-core/src/erc4337.rs:
- register_agent_device_calldata  — registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)
- set_scope_calldata              — setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)
- execute_batch_calldata          — executeBatch(address[],uint256[],bytes[])
- accept_batch_calldata           — the headline: executeBatch([register, setScope]);
                                     threads the agent's actor_omni into both inner calls
                                     so they can't disagree on which agent they bind.

Hand-rolled ABI (no alloy/ethabi — matches sponsor.rs/audit::calldata style), reusing
the public audit::calldata::selector so selectors never drift. Golden-tested byte-for-byte
against foundry cast for all three:
  cast calldata "registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)" ...
  cast calldata "setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)" ...
  cast calldata "executeBatch(address[],uint256[],bytes[])" "[reg,scope]" "[0,0]" "[reg_cd,scope_cd]"
fixtures committed under src/testdata/. cargo test + clippy green.

First slice of #225; the submission client (#200 Stage B), the daemon wiring, the
browser ceremony, and the on-chain cutover remain (tracked in #225).

* feat: #225 E7 — assemble the sponsored accept UserOp (broker composer)

Ties the two existing halves into one ready-to-sign PackedUserOperation:
- intent: agentkeys_core::erc4337::accept_batch_calldata (the atomic
  executeBatch([registerAgentDevice, setScope]), P.2+P.3)
- sponsorship: the broker EIP-191 co-signs the VerifyingPaymaster getHash
  (J1-gated Sybil gate = gas-free), via crate::sponsor (#200 Stage A).

New crates/agentkeys-broker-server/src/sponsored_accept.rs:
- AcceptUserOpParams — every chain-derived value (nonce/gas/fees/validity/addrs)
  is an explicit input (nothing hardcoded; caller reads them on-chain).
- assemble_accept_userop(params, broker_sk) -> AssembledAcceptUserOp { user_op,
  user_op_hash, paymaster_get_hash }. Sets paymasterAndData[20:52] (the gas word)
  provisionally so paymaster_get_hash commits the limits the broker approves, then
  rebuilds paymasterAndData with the real co-sign appended; computes the userOpHash
  the master K11 signs. Pure (broker key only, no chain I/O).

Broker-side because the paymaster co-sign needs the broker key; the daemon will
call this via an endpoint and just K11-sign the returned userOpHash (the #200
division of labour). 3 unit tests: callData==accept batch + sender==master +
empty account sig + deterministic hash; paymasterAndData layout + broker co-sign
recovers to the broker EOA; grant change => userOpHash change. cargo test + clippy green.

Slice 2 of #225. Next: the broker HTTP endpoint wrapping this + the daemon call +
the Stage-B handleOps submit (cast-based, mirrors the E8 proof). Refs #225.

* feat: #225 E7 — accept-flow wire types (backend-client, #204 one-owner)

Defines the daemon<->broker protocol for the on-chain K11-gated accept, in the
ONE owner crate per the #204 rule (the daemon deps backend-client; the broker
mirrors these shapes server-side, pinned by the frozen key-set tests):

- BuildAcceptUserOpRequest  — POST /v1/accept/build (J1_master): register fields
  (device_key_hash, agent_pop_sig, link_code_redemption) + the granted scope
  (services + u128 caps as wire-safe decimal strings + period_seconds).
- WireUserOp                — ERC-4337 v0.7 PackedUserOperation, hex per field;
  mirrors broker sponsor::PackedUserOp. The daemon fills  with the
  master K11 assertion over user_op_hash.
- BuildAcceptUserOpResponse — { user_op, user_op_hash, entry_point, chain_id }.
- SubmitAcceptUserOpRequest / SubmitAcceptUserOpResponse — POST /v1/accept/submit
  → EntryPoint.handleOps (Stage B), returns { ok, tx_hash, block_number }.

Fixtures regenerated via dump-protocol-fixtures + frozen key-set tests for the
three request bodies (build_accept_userop_request, wire_user_op,
submit_accept_userop_request). cargo test + clippy + fixture --check green.

Slice 3 of #225. Next: the broker /v1/accept/{build,submit} handlers (mirror these
shapes server-side, gate on J1, call assemble_accept_userop) + the daemon call +
K11-sign. Refs #225.

* feat: #225 E7 — PackedUserOp→wire conversion + /v1/accept/build response

The connective piece the broker accept handler returns: convert the internal
sponsor::PackedUserOp into the hex-encoded wire shape and shape the build body.

crates/agentkeys-broker-server/src/sponsored_accept.rs:
- WireUserOp — broker-side mirror of backend_client::protocol::WireUserOp (the
  broker doesn't dep that crate; frozen key-set tests on both sides pin them).
- WireUserOp::from_packed — hex-0x each PackedUserOp field.
- BuildAcceptResponse + AssembledAcceptUserOp::into_build_response — the
  /v1/accept/build body { user_op, user_op_hash, entry_point, chain_id }.

3 unit tests: every wire field round-trips back to the original bytes; the build
response carries the accept-batch callData + the userOpHash + entry_point/chain_id;
WireUserOp JSON keys match the backend-client frozen shape (server-side #204 pin).
cargo test + clippy green.

Slice 4 of #225. Next (the I/O layer, happy-path gated on a deployed P256Account
master): the axum /v1/accept/{build,submit} routes — J1_master auth (mirror
mint_cap) + eth_call operatorMasterWallet/getNonce + assemble_accept_userop +
into_build_response; submit relays EntryPoint.handleOps. Refs #225.

* docs: #225 E7 — scope the account-auth cutover + onboarding-as-account

The precise, idempotent spec for the live-mainnet cutover that unblocks the #225
e2e (PR #227's /v1/accept flow needs the master to BE a deployed P256Account, not
the current EOA). docs/plan/chain/account-auth-cutover.md specifies:

- The gap: registry/scope sources are account-auth in code (E3) but the LIVE
  bytecode is pre-E3; heima-bring-up's cast-code idempotency check skips the
  redeploy, so account-auth never goes live.
- The consequence (loud): DeployAgentKeysV1 redeploys to NEW addresses → all
  on-chain state (master, agents, scopes, epoch, audit) resets → full re-bootstrap;
  demo breaks until re-bootstrapped. Operator-gated, announced, NOT in the plain flow.
- 6 phases (pre-flight → redeploy v2 set FORCE_DEPLOY → redeploy P256AccountFactory
  → onboarding-as-account → re-bootstrap actors → code/doc updates → broker redeploy),
  each idempotent with explicit skip checks.
- Idempotency strategy for a REDEPLOY (cast-code alone is insufficient since the old
  contracts also have code): a CUTOVER_DONE_<profile> marker + a live setScope
  account-auth ABI capability probe.
- The two scripts to implement (heima-cutover-account-auth.sh +
  heima-deploy-master-account.sh), the setup-heima.sh --cutover-account-auth wiring,
  the #201 env 3-file discipline, rollback (restore the .pre-cutover.bak env), and
  the arch.md §10/§12 + deployed-contracts.md sync owed at Phase 5.

Refs #225. Scopes the cutover named in erc4337-master-account.md §3.1.

* docs: #225 — cutover spec reuses erc4337-register-master.sh; decouple master-as-account

Diligence correction to the cutover spec after finding the onboarding-as-account
step already exists:

- Phase 3 (onboarding-as-account) reuses the existing `erc4337-register-master.sh`
  (build+submit) — it already does factory.createAccount + EntryPoint-deposit +
  register-first-master-as-account, idempotently. Dropped the proposed (redundant)
  `heima-deploy-master-account.sh`; only ONE new script remains (the cutover
  orchestrator `heima-cutover-account-auth.sh`).
- Decoupling finding (from that script's header): master-as-account is VIABLE on
  the LIVE pre-cutover registry (no EOA-only guard), so operatorMasterWallet[omni]
  can be the P256Account TODAY — no disruptive redeploy needed for that half.
  The cutover is only required for the accept batch's setScope (P.3): the live
  scope has setScopeWithWebauthn, not the msg.sender-gated setScope. So work can
  stage: register master-as-account now + exercise /v1/accept/build against it;
  do the registry/scope redeploy only when account-auth setScope is needed e2e.

Refs #225.

* feat: #225 E7 — heima-cutover-account-auth.sh (the account-auth cutover orchestrator)

The one new script the cutover spec calls for. Forces a redeploy of the v2 set so
the account-auth sources (E3) go live, making the #225 accept batch's setScope (P.3)
real. Idempotent + safe + bash -n clean.

scripts/heima-cutover-account-auth.sh:
- Phase 0: pre-flight (assert local AgentKeysScope.sol is account-auth — setScope
  present, setScopeWithWebauthn gone) + back up the env addresses to
  operator-workstation.env.pre-cutover.bak (idempotent: skip if present).
- Phase 1: redeploy via FORCE_DEPLOY=1 heima-bring-up.sh, then verify + set the
  CUTOVER_DONE_<profile> marker. DESTRUCTIVE → gated behind --yes; refuses otherwise.
  Idempotency ground truth is a read-only probe: the live scope bytecode carrying the
  setScope selector d8e9e3c6 (the marker is just the fast path).
- Phase 2: factory CHECK only (E5 recover() isn't needed for accept; no reusable
  factory-deploy helper exists, so it doesn't blind-deploy).
- Prints the follow-ups: re-register master-as-account (erc4337-register-master.sh),
  re-bootstrap agents/scopes, the repo edits (heima-scope-set.sh→setScope, arch.md),
  broker redeploy (setup-broker-host.sh --ref main).

Classified as a directly-callable SURGICAL helper (the three-entry-points exemption
for destructive heima-*-revoke/-rotate tools) — NOT wired into setup-heima.sh's plain
flow, since a plain run must never reset on-chain state. Spec updated to match.

Verified: bash -n clean; --help + unknown-arg guard work; setScope selector d8e9e3c6
confirmed against the earlier cast golden vectors. Cannot run e2e here (live mainnet
redeploy). Refs #225.

* fix: #225 — heima-cutover-account-auth.sh resolves RPC via agentkeys chain show

The script died immediately with "no RPC" because it used a made-up resolution
(AGENTKEYS_CHAIN_RPC_HTTP — a broker-runtime var — plus an invented RPC_HTTP_HEIMA
fallback), neither of which operator-workstation.env carries. Diagnosis: both
heima-bring-up.sh:122 and setup-heima.sh:195 resolve the chain RPC the same way —
`agentkeys chain show "$CHAIN" | jq -r .rpc.http` (no RPC env key exists). Switched
to that; added jq + agentkeys to the tool pre-check. Verified live: it now resolves
https://rpc.heima-parachain.heima.network and runs to the destructive --yes gate.

Also: back up the env to $HOME/.agentkeys/<name>.pre-cutover.bak instead of next to
the git-tracked operator-workstation.env (a .bak there would surface as untracked).
Verified the backup lands in ~/.agentkeys and leaves git status clean.

Other assumptions re-checked against reality (correct): the SCOPE/REGISTRY/FACTORY
address keys exist in operator-workstation.env; the profile suffix uses the sibling
idiom tr 'a-z-' 'A-Z_'; the phase-0 guard holds (source AgentKeysScope.sol has
setScope, no setScopeWithWebauthn). Refs #225.

* docs: #225 — account-auth cutover operator runbook (+ correct the post-cutover re-bind path)

Adds docs/operator-runbook-account-auth-cutover.md — the full 5-step operator procedure
for the disruptive cutover, in the operator-runbook-*.md convention (H1, > warning blocks,
ordered steps, rollback).

Writing it surfaced a correctness bug in the earlier spec + the script's printed follow-ups:
post-cutover, agent binding + scope grants go through ACCOUNT UserOps (the #225 accept
flow), because account-auth gates registry/scope writes on msg.sender == operatorMasterWallet
(the P256Account). The pre-cutover scripts do NOT work post-cutover — verified:
  - heima-agent-create.sh sends registerAgentDevice from the deployer EOA (≠ the account);
  - heima-scope-set.sh calls setScopeWithWebauthn (the assertion-in-calldata path account-auth
    removes; the new setScope is msg.sender-gated, no assertion param).
So the runbook leads with two warnings: (1) SEQUENCING — run the cutover only AFTER the
#225 accept flow is wired, else agents are stranded (you can re-register the master but not
re-bind agents); (2) DESTRUCTIVE — state reset → full re-bootstrap.

Corrected to match:
- spec Phase 4 (re-bind = #225 accept flow, not heima-agent-create/heima-scope-set);
- spec Phase 5 (drop the bogus heima-scope-set.sh setScopeWithWebauthn→setScope edit — it's a
  pre-cutover tool, retired post-cutover; just arch.md §10/§12 + deployed-contracts.md);
- the script's printed follow-ups (point at the #225 accept flow + the new runbook).

Verified: script bash -n clean; runbook H1/no-frontmatter/warnings present. Refs #225.

* docs: #225 — simplify cutover runbook (dev-only: cutover = 1-3, then re-onboard) + fix step-4 command

Per the "no user, only developer, register again" reality:
- Reframe: nothing to migrate. The cutover proper is redeploy + verify + broker redeploy
  (steps 1-3); registering the master + pairing agents (4-5) is just normal onboarding on
  the fresh contracts, not a special re-bootstrap. Dropped the "DESTRUCTIVE / announce +
  schedule / state NOT migrated" alarm.
- Master register is still REQUIRED (the new registry is empty → registerAgentDevice would
  revert OperatorNotRegistered), but it's one command, not the placeholder build/submit dance:
      bash harness/scripts/erc4337-register-master.sh --operator-omni 0x<omni>   # auto Touch ID
  The old step-3 block was not executable (raw 0x<…>/<N> placeholders + a hand-wavy "K11 signs
  the userop_hash"). The default `register` mode auto-runs k11 webauthn-keygen + webauthn-userop-sign;
  the build/submit two-phase split is only for the browser web-flow.
- Synced the script's printed follow-ups + the spec Phase 3 to the one-command form.

Refs #225.

* feat: unpair button + ON-CHAIN device revoke (was local-only)

The "accept pairing" had no unpair, and the revoke that did exist (actor-detail
view) only flipped LOCAL daemon state — the device stayed registered on chain. Both
gaps closed:

Daemon (ui_bridge.rs):
- revoke_device now shells out to heima-device-revoke.sh (--agent <label>; agent-tier
  needs no K11; idempotent) BEFORE flipping local state — a binding isn't gone until
  SidecarRegistry.revokeAgentDevice says so. On-chain failure returns 502 and leaves
  local state untouched (no silent local-only revoke). New helpers: resolve_repo_script
  (mirrors register_pairing's heima-agent-create.sh resolution) + revoke_agent_device
  (mirrors register_agent_device). Errors loudly if --register-master-script / the
  revoke script is absent (chain-unconfigured). Test updated to mock the script + a
  make_state_with_script helper; passes. clippy clean.

Frontend:
- Unpair button on each paired-device card (pairing.tsx) → onUnpair → the existing
  K11/confirm revoke flow.
- confirmAction now AWAITS client.revokeDevice + re-fetches the authoritative actor
  tree, instead of fire-and-forget + optimistic flip — so a failed on-chain revoke
  surfaces and a success reflects the real chain state. tsc clean.

NOTE: this revoke is currently deployer-signed (like accept). Per the "sensitive
UserOps need Touch ID" task, it joins accept on the list to be K11-gated (arch.md +
the real gate, next).

* docs: arch.md §10.1a — canonical list of Touch-ID-gated (sensitive) operations

Per the "any sensitive UserOp must be Touch-ID-gated" requirement: formalized the
inline enumeration at §10 (scope grant/revoke, device add/revoke, K10 rotation,
recovery, audit-row mint, typed-data sign) into an explicit table in a new §10.1a.

States the rule — every master-authority mutation is a P256Account UserOp, and
every P256Account UserOp is K11-gated by validateUserOp (challenge == userOpHash) —
maps each sensitive op to its on-chain call + UI trigger + gate status, and marks
accept / unpair / scope-grant as ⏳ #225 (deployer-signed today, no Touch ID — the
gap between the rule and the running code, being closed by E7 + the cutover). Also
draws the authority-vs-usage boundary: cap-mint + worker reads/writes are J1+cap
gated, NOT per-op Touch ID (re-prompting per memory read would be unusable), except
high-value payments above payment_k11_threshold.

Single source of truth (terminology rule) — extends the existing §10 enumeration
rather than duplicating it. Refs #225.

* feat: #225 E7 (2b slice 1) — broker /v1/accept request type + pure parser

Starts the real Touch-ID gate (task 2b). The broker handlers/accept.rs:
- BuildAcceptRequest — server-side mirror of the backend-client wire type (the
  /v1/accept/build body, J1_master-gated; broker doesn't dep backend-client, the
  frozen key-set test there pins the shape).
- parse_register_and_grant — pure parse of the wire request into the typed
  agentkeys_core::erc4337 AgentRegister + ScopeGrant that assemble_accept_userop
  consumes. Service strings → bytes32 via keccak256(lowercase(service)) — the SAME
  hash heima-scope-set.sh writes (verified: keccak("memory:personal") golden), so a
  service id is byte-identical on every path. Caps as decimal strings (wire-safe).

3 unit tests (golden service-id, lowercasing, bad-hex/short/non-numeric rejection);
cargo test + clippy green.

Next 2b slices: the axum /v1/accept/build handler (J1 auth like mint_cap + eth_call
operatorMasterWallet/getNonce + assemble_accept_userop + into_build_response), which
needs new ENTRYPOINT/PAYMASTER env (3-file discipline) + the broker EVM co-sign key
loaded; then /v1/accept/submit (handleOps, Stage B); the daemon accept wiring; the
ceremony.tsx browser Touch ID over the userOpHash. Refs #225.

* feat: #225 E7 (2b slice 2) — broker POST /v1/accept/build handler

The keystone of the Touch-ID gate: J1_master-gated, assembles the sponsored
executeBatch([registerAgentDevice, setScope]) UserOp and returns the userOpHash
the master K11-signs.

handlers/accept.rs:
- build_accept_response (PURE, tested): request + chain reads (master account +
  nonce) + config + broker co-sign key → BuildAcceptResponse (via the slice-1 parser
  + sponsored_accept::assemble_accept_userop + into_build_response).
- accept_build (axum): bearer + verify_session_jwt + operator_omni == session omni;
  load_accept_config (env: ENTRYPOINT/PAYMASTER/BROKER_SPONSOR_SIGNER_{ADDRESS,KEY},
  registry/scope profile-aware, gas defaults as named consts); eth_call
  operatorMasterWallet (404→CONFLICT if no master) + getNonce; build_accept_response.
- Route POST /v1/accept/build wired in lib.rs.

4 unit tests (parser ×3 + build_accept_response assembles the batch op: sender==master,
0x47e1da2a executeBatch callData, userOpHash present). cargo build + clippy green.

Live prereqs (operator): the new ENTRYPOINT/PAYMASTER/sponsor-key env (set by
setup-broker-host.sh) + a deployed P256Account master (the cutover). Next: slice 3
/v1/accept/submit (handleOps, Stage B). Refs #225.

* feat: #225 E7 (2b slice 3) — broker POST /v1/accept/submit (handleOps, Stage B)

Relays the K11-signed accept UserOp to EntryPoint.handleOps — the broker is sponsor
+ submitter (VerifyingPaymaster covers the account gas; the broker EOA fronts the
outer tx, reimbursed). Submits via `cast send` (the repo's chain-mutation pattern;
E8 proved the handleOps incantation on mainnet; the broker host ships foundry).

handlers/accept.rs:
- SubmitAcceptRequest (mirror; user_op.signature now carries the K11 assertion).
- cast_handleops_arg (pure, tested) — WireUserOp → the cast PackedUserOperation tuple
  for handleOps((address,uint256,bytes,bytes,bytes32,uint256,bytes32,bytes,bytes)[],address).
- accept_submit (axum): J1 auth + cast send handleOps + parse {tx_hash, block_number}.
- Route POST /v1/accept/submit wired.

5 unit tests green; clippy clean. NOTE: --private-key is ps-visible — production should
move the submitter to the broker fee-payer keystore (follow-up, commented in code).
Next: slice 4 daemon accept wiring (build → browser K11-sign → submit). Refs #225.

* feat: #225 E7 (2b slice 4) — daemon /v1/accept/{build,submit} proxy routes

The daemon bridge for the Touch-ID-gated accept: the browser calls build → does
navigator.credentials.get() (Touch ID) over the returned userOpHash → calls submit.
The daemon forwards both to the broker with the master J1; device fields come from
the broker's AUTHORITATIVE pending binding (never the browser), scope from the UI.

ui_bridge.rs:
- accept_build_proxy: resolve broker + J1 + operator_omni (master session) + the
  binding row (agent_pending_value → child_omni/device_key_hash/pop_sig) → POST
  broker /v1/accept/build → relay {user_op, user_op_hash, entry_point, chain_id}.
- accept_submit_proxy: relay the K11-signed op → broker /v1/accept/submit.
- forward_to_broker helper (bearer J1, verbatim status+body relay).
- Routes /v1/accept/{build,submit} wired.

cargo build + clippy green. (link_code_redemption "0x" — accepted-but-unused by
registerAgentDevice.) Next: slice 5 — the browser ceremony in ceremony.tsx/pairing.tsx
(call build → Touch ID → submit). Refs #225.

* feat: #225 E7 (2b slice 5) — browser Touch ID accept ceremony

The accept button now does the REAL Touch-ID gate, replacing the deployer-signed
registerPairing: build → navigator.credentials.get() over the userOpHash → submit.

- webauthn.ts: getAssertionOverHash(userOpHash) — navigator.credentials.get with the
  userOpHash AS the raw WebAuthn challenge (arch.md §22b.1, no sha256 wrap), returning
  the raw assertion {authenticator_data, client_data_json, signature, credential_id}.
- client (types/daemon/empty): acceptBuild + acceptSubmit (POST the daemon
  /v1/accept/{build,submit} proxies).
- App.tsx acceptPairing: derive the approved memory:<ns> services from the request →
  acceptBuild → Touch ID over user_op_hash → acceptSubmit({user_op, assertion}).

tsc clean. FINAL MILE (documented): the broker /v1/accept/submit must encode the
assertion into the P256Account UserOp signature (abi.encode(credIdHash, authData,
clientDataJSON, loc, r, s) — the same format the Rust CLI's k11_webauthn /
erc4337-master-e8.sh already produce) before handleOps; today it expects a pre-signed
op. That + the live cutover + hardware Touch ID are the remaining operator-side
verification. Refs #225.

* feat: #225 E7 — encode_webauthn_signature (the browser-assertion → UserOp signature)

The crypto bridge slice 5 needs: encode the browser's WebAuthn assertion into the
P256Account UserOp signature so EntryPoint.handleOps accepts it.

core::erc4337::encode_webauthn_signature = abi.encode(bytes32 credIdHash, bytes
authenticatorData, bytes clientDataJSON, uint256 challengeLocation, uint256 r,
uint256 s) — the exact P256Account.validateUserOp format (== the CLI's
k11 webauthn-userop-sign + erc4337-master-e8.sh byte spec). Golden-tested vs
`cast abi-encode` (fixture committed). Reusable by both the broker accept_submit and
the CLI. cargo test + clippy green.

Next: wire it into the broker accept_submit (DER r/s extraction + cred_id_hash). Refs #225.

* fix: heima-bring-up.sh FORCE_DEPLOY=1 now actually forces a redeploy (was a no-op)

Reported by an operator running `FORCE_DEPLOY=1 bash scripts/heima-bring-up.sh` for
the #225 account-auth cutover: it printed "ALL 4 contracts already deployed → skip
deploy" and did NOTHING, despite FORCE_DEPLOY=1.

Root cause: step 5's `if [ "$ALL_DEPLOYED" = "1" ]` skip fired UNCONDITIONALLY when
all 4 stored addresses had on-chain code — FORCE_DEPLOY was only consulted in the
ELSE branch's unknown-address (0x0/empty) refusal, never for the has-code case. So
FORCE_DEPLOY=1 could never force a redeploy of existing contracts — exactly what the
cutover needs (redeploy the account-auth v2 set to NEW addresses).

Fix: gate the skip on `&& [ "${FORCE_DEPLOY:-0}" != "1" ]`, so FORCE_DEPLOY=1 falls
through to the deploy branch (the unknown-address refusal stays unchanged — DEPLOY_REASON
is empty when ALL_DEPLOYED=1, so no false refusal). Added a LOUD warning that a
force-redeploy over existing on-chain code orphans the old contracts + all their state
(registrations/scopes/epoch/audit) and costs real mainnet gas. Normal/CI runs (no
FORCE_DEPLOY) are unchanged — they still skip. env_set already clobbers, so the new
addresses persist. Folded the fix note into the cutover spec Phase 1. bash -n clean.

* docs: cutover script — QUICK START + all session findings in the header

heima-cutover-account-auth.sh now opens with a copy-paste QUICK START (the full
6-step operator sequence: redeploy → verify → re-register master → broker redeploy
→ re-bind agents via the #225 accept flow → doc commit) and a FINDINGS block:
- FORCE_DEPLOY fix (it was a no-op when contracts had on-chain code; Phase 1 depends
  on the fix);
- SEQUENCING (run only after the #225 accept flow is wired, else agents are stranded
  post-cutover — the pre-cutover heima-agent-create/heima-scope-set scripts stop
  working);
- ACCEPT-FLOW FINAL MILE (accept_submit must encode the assertion via the golden-
  tested encode_webauthn_signature; + new broker env + a funded submitter);
- DECOUPLING (master-as-account works on TODAY's contracts; you may not need the
  cutover yet — try the non-destructive path first).

Also corrected the stale Phase-4 line (re-bind is the accept flow, not the old
deployer-EOA scripts) and widened the --help range (2,68) so it prints the full
header. bash -n clean; --help verified.

* docs: cutover operator runbook — Quick start block + session findings

Added to docs/operator-runbook-account-auth-cutover.md:
- ## Quick start — the copy-paste 6-step sequence (redeploy → verify → broker
  redeploy → re-register master → re-bind via accept flow → doc commit).
- ⚠ Status blockquote — the #225 accept flow's FINAL MILE is open: accept_submit
  must encode the assertion via the golden-tested encode_webauthn_signature (+ the
  cred_id_hash derivation), plus new broker env (ENTRYPOINT/PAYMASTER/
  BROKER_SPONSOR_SIGNER_*) + a funded submitter. So steps 1-4 work today; step 5
  (re-bind agents) does not yet.
- FORCE_DEPLOY-fix finding at step 1 (the flag was a no-op when contracts had code;
  fixed 2026-06-08 — need a checkout with the fix or the cutover silently no-ops).

* fix: don't blind-redeploy when expected code is live; enforce deploy→doc-sync

Two operator-reported gaps:

1. "If the expected code is already on-chain, we should not redeploy." My earlier
   FORCE_DEPLOY fix made `FORCE_DEPLOY=1 heima-bring-up.sh` a BLIND force — it
   redeploys whenever the stored address has code, without checking it's the
   EXPECTED version. heima-bring-up.sh can't robustly know "expected" (bytecode
   comparison is fragile — metadata hash, immutables). The SEMANTIC check belongs
   in the cutover script, which already has it (the live-scope setScope-selector
   probe → skip if account-auth is already live). So: the FORCE_DEPLOY warning now
   redirects to scripts/heima-cutover-account-auth.sh (the idempotent path that
   skips when the expected code is live) and is explicit that FORCE_DEPLOY is blind.

2. "As long as we deploy a new contract we need to update deployed-contracts.md and
   arch.md." A deploy updates operator-workstation.env (env_set) but NOT the
   canonical docs. heima-bring-up.sh now prints a loud "FRESH DEPLOY — update
   deployed-contracts.md + arch.md §5" reminder on every real deploy (DEPLOYED_FRESH
   flag). Added the matching HARD rule to CLAUDE.md "Deployed contract registry"
   (both directions: deploy→doc-sync, and don't-redeploy-if-expected-code-live),
   noting a future CI guard should diff deployed-contracts.md vs the env addresses.

bash -n clean.

* ci: enforce deployed-contracts.md ⊇ operator-workstation.env contract addresses

Folds the "a new deploy must update the canonical docs" rule into ACTUAL CI
enforcement (not just a reminder + a CLAUDE.md note):

- scripts/check-deployed-contracts-sync.sh: sources operator-workstation.env, and
  for each prod-Heima contract key (scope/registry/epoch/audit/p256-verifier/
  k11-verifier/entrypoint/factory) verifies its non-sentinel address appears in
  docs/spec/deployed-contracts.md (case-insensitive). Exits 1 listing any missing.
  Verified locally: all 8 in sync → exit 0.
- .github/workflows/deployed-contracts-sync.yml: runs the check on PRs touching the
  env, the registry doc, or the check itself (+ push to main).
- CLAUDE.md "Deployed contract registry": the rule now says CI ENFORCES it (was "a
  future CI guard should…").

So a cutover/redeploy that lands a new address in the env now fails the PR until the
registry + arch.md are updated — closing the drift the operator flagged. bash -n clean.

* agentkeys: fold contract registry into the chain profile + record v0.1

The chain profile crates/agentkeys-core/chain-profiles/<chain>.json is now the
single machine-readable source of truth for deployed addresses + the deployed
contract-set version (contract_set_version). heima-bring-up.sh rewrites it
programmatically on every fresh deploy and gates redeploys on VERSION, not
bytecode. Records the force-redeployed account-auth set as version 0.1 and
propagates the #164/#225 account-auth cutover everywhere it had drifted.

Registry / versioning:
- ChainProfile gains contract_set_version (Option<String>); heima.json now
  carries all 8 contracts (added EntryPoint + P256AccountFactory) + version 0.1.
- Pinning test reframed to validate shape + completeness + version (not exact
  addresses, which the deploy script now writes) -> no per-deploy test churn.
- heima-bring-up.sh: VERSION (expected) vs profile contract_set_version
  (recorded) drives the deploy decision; a mismatch while code is live is a
  hard stop (no silent mainnet re-mint). Step 6b jq-writes the profile on deploy.
- crates/agentkeys-chain/VERSION (new) = 0.1, the expected source version.
- Dropped docs/spec/deployed-contracts.json (folded into the profile) + the
  per-PR CI workflow (local check only -- CI is expensive).
- check-deployed-contracts-sync.sh rewritten: chain profile <-> env.
- deployed-contracts.md: address table -> pointer to the profile; prose kept.

Account-auth cutover propagation (the live AgentKeysScope is now setScope):
- audit_decode scope.grant -> setScope (sel 0xd8e9e3c6); calldata::REGISTRY
  retains setScopeWithWebauthn only for orphaned pre-cutover calldata decode.
- New core addresses across env + heima.json + the audit-decode test.
- deployed-contracts.md SidecarRegistry/AgentKeysScope ABI summaries aligned to
  the live account-auth source.

Tests: agentkeys-core 165+3 green; daemon audit_decode 4 green;
check-deployed-contracts-sync.sh green (8 contracts, version 0.1).

* docs: account-auth cutover runbook — add the accept-flow Solution (was only the problem)

The ⚠ Status block named the final-mile gap but gave operators no path out of the
`POST /v1/accept/build → 503 BROKER_SPONSOR_SIGNER_KEY not set` (+ no Touch-ID prompt).
Adds a brief, actionable Solution: (1) broker env that unblocks build + the Touch-ID
prompt (BROKER_SPONSOR_SIGNER_{KEY,ADDRESS} + PAYMASTER_ADDRESS on the host;
ENTRYPOINT_ADDRESS_HEIMA already set), with the VerifyingPaymaster-not-deployed gotcha
and the sponsored-vs-unsponsored fork; (2) wire accept_submit -> encode_webauthn_signature
+ cred_id_hash; (3) funded submitter EOA. Step 1 unblocks Touch-ID today; 2-3 the on-chain
accept.

* agentkeys: unsponsored accept keystone — /v1/accept/build works without a paymaster

The accept flow's /v1/accept/build 503'd on `BROKER_SPONSOR_SIGNER_KEY not set` and
required PAYMASTER_ADDRESS, but the VerifyingPaymaster is intentionally not deployed —
so no Touch-ID prompt ever appeared. Align the broker to the mainnet-proven UNSPONSORED
direct-handleOps model (erc4337-register-master.sh):

- load_accept_config: PAYMASTER_ADDRESS + BROKER_SPONSOR_SIGNER_ADDRESS are now OPTIONAL.
  Unset PAYMASTER => unsponsored (the default); the beneficiary defaults to the submitter
  key's own address. Only BROKER_SPONSOR_SIGNER_KEY (the funded submitter EOA) is required.
- assemble_accept_userop / AcceptUserOpParams: paymaster is Option; None => empty
  paymasterAndData, no broker co-sign, zero paymaster_get_hash; userOpHash over the
  empty-paymaster op.

So once the operator sets BROKER_SPONSOR_SIGNER_KEY on the broker host, /v1/accept/build
returns 200 and the browser Touch-ID ceremony runs.

Runbook: the account-auth cutover Solution now reflects the unsponsored default (one env
var) + the deeper on-chain-submit findings (synthetic credIdHash convention
keccak("agentkeys-register-cred:0x<omni>"), the same-passkey prerequisite, the account
EntryPoint deposit) for the follow-up submit wiring.

Tests: broker lib 208 passed (new unsponsored_* tests + updated sponsored tests). The
on-chain submit (decode -> handleOps) + the cross-stack wire are the remaining #225
final-mile; a live run needs hardware Touch ID + a funded EOA.

* agentkeys: accept submit-encoding keystone — credId convention + browser-assertion decoder

The on-chain accept's missing piece: turn the browser WebAuthn assertion into the
P256Account UserOp signature. Two tested, reusable pieces (no chain I/O):

- core::erc4337::master_cred_id_hash(operator_omni) + MASTER_CRED_ID_PREFIX — the ONE
  definition of the synthetic credId convention the master account is created with
  (keccak("agentkeys-register-cred:0x"+omni)); the accept signature must carry the SAME
  hash or P256Account reverts UnknownSigner. Terminology source-of-truth: the
  erc4337-register-master.sh literal must match it.
- broker::accept_assertion::encode_browser_assertion_signature — base64url decode +
  p256 DER->(r,s) + challenge-location + the operator-derived credId -> core's
  golden-tested encode_webauthn_signature. Mirrors the mainnet-proven CLI
  extract_chain_assertion (the p256 dep lives in the broker, not core).

Tests: core erc4337 6 passed; broker accept_assertion 2 passed (round-trip + negative).
Next: wire accept_submit (decode -> handleOps) + the protocol/daemon/browser shape, then
deploy + fund + wire the VerifyingPaymaster (the sponsored gas path).

* agentkeys: wire accept_submit — broker encodes the browser assertion → handleOps

The on-chain submit now lands: /v1/accept/submit carries the raw browser WebAuthn
assertion ({user_op, assertion}); the broker encodes it into the P256Account UserOp
signature and relays to EntryPoint.handleOps (sponsored — the paymaster covers gas).

- broker accept_submit: decode assertion via accept_assertion::encode_browser_assertion_
  signature → set user_op.signature → handleOps. operator_omni (→ the credIdHash signer
  key) is derived from the VERIFIED J1 session omni, not a body field — authoritative +
  unspoofable, and the browser already sends {user_op, assertion} (no frontend change).
- protocol: SubmitAcceptUserOpRequest += assertion (new AcceptAssertion type); fixtures +
  frozen key-set test updated (keys: assertion, user_op) + the committed JSON regenerated.
- daemon accept_submit_proxy is a verbatim relay (untyped) — no change needed.

Tests: broker lib 210 passed; backend-client 12 passed (frozen + fixture --check green).
Remaining for live: deploy + fund + wire the VerifyingPaymaster (sponsored gas path) +
the broker BROKER_SPONSOR_SIGNER_KEY env; then a live run (hardware Touch ID + funded
paymaster + a browser-registered master).

* agentkeys: paymaster route — VerifyingPaymaster deploy helper + heima-bring-up wiring

The sponsored gas path for the one-Touch-ID accept (atomic executeBatch register+scope).
The broker co-sign + sponsored assemble were already built (paymaster: Some); this adds the
deploy + funding + the operator runbook.

- scripts/heima-deploy-paymaster.sh (new, idempotent): forge-create VerifyingPaymaster
  (entryPoint, brokerSigner, owner); skip-if-on-chain-code; setBrokerSigner only on drift;
  deposit() only below threshold; generates ~/.agentkeys/broker-sponsor-signer.key (0600) if
  absent; records PAYMASTER_ADDRESS_<p> + BROKER_SPONSOR_SIGNER_ADDRESS_<p> into
  operator-workstation.env. --dry-run; result JSON on stdout, logs on stderr.
- heima-bring-up.sh step 6c: ensure the paymaster after the cores (idempotent, NON-fatal —
  the broker degrades to unsponsored without it; ENABLE_PAYMASTER=0 to skip). So setup-heima.sh
  deploys it transitively (no step renumbering).
- Runbook: the Solution is now the sponsored/paymaster route — what's landed (submit encoding +
  sponsored config + the deploy helper) + the operator steps (deploy/fund, broker env via a 0600
  EnvironmentFile for the key, the same-passkey prerequisite).

Flagged-not-done: setup-broker-host.sh auto-wiring of BROKER_SPONSOR_SIGNER_KEY +
PAYMASTER_ADDRESS (private-key 0600-EnvironmentFile handling on the live host, untested here),
and the live on-chain run (paymaster deployed+funded, browser-registered master, hardware
Touch ID — operator actions). Scripts syntax-checked (bash -n).

* fix: heima-deploy-paymaster.sh — deploy via cast send --create, not forge create

The first run died silently right after "deploying…". Diagnosed (deployer 0xdE64…
has 20.99 HEI, EntryPoint live, VerifyingPaymaster.sol compiles — so not funds / RPC /
compile): forge 1.6/1.7's `forge create` does a pre-broadcast gas estimation that errors
on Heima BEFORE sending (deployer nonce unchanged at 306 → no tx, no orphan), and the
address-extraction grep then tripped pipefail+set-e, killing the script with no error
shown.

Fix: deploy via `cast send --create` (the repo's proven chain-mutation path) at the
DETERMINISTIC CREATE address (deployer ‖ nonce via cast compute-address), `|| true` +
on-chain has_code verify (mixHash-receipt-proof), dumping the full cast output on failure
(no more silent exit). Mirrors erc4337-register-master.sh's cast-send posture.

* agentkeys: wire the accept-flow broker env into setup-broker-host.sh (#225 sponsored)

The broker is a systemd service (User=agentkeys, ProtectHome=true), so the sponsor key can
NOT live in ~/.zshenv — systemd never sources a shell rc + ProtectHome hides home dirs. And a
hand-edited unit is wiped on the next setup-broker-host.sh (it regenerates the unit). So wire
the accept-flow env into the generator (mirrors the $CRED_LINE conditional-line pattern):

- Environment=ENTRYPOINT_ADDRESS_HEIMA / PAYMASTER_ADDRESS_HEIMA — public addresses from the
  sourced operator-workstation.env, emitted CONDITIONALLY (no empty PAYMASTER= line, which would
  break load_accept_config into a 503).
- EnvironmentFile=-/etc/agentkeys/broker-sponsor.env — the SECRET BROKER_SPONSOR_SIGNER_KEY,
  operator-owned 0600 (systemd reads it as root pre-privilege-drop; /etc is outside ProtectHome).
  The `-` makes it optional: absent => broker boots UNSPONSORED, no hard-fail. Never inline
  Environment= (systemctl show + /proc/<pid>/environ would leak it).

Runbook step 2 rewritten: ~/.zshenv won't work; the 0600 EnvironmentFile + the
setup-broker-host.sh --ref main redeploy. bash -n + unit-render simulated (paymaster set/unset).

* docs: runbook — extract the sponsor key VALUE (not cat ~/.zshenv) into broker-sponsor.env

~/.zshenv is a shell script, so `cat ~/.zshenv` would dump every line into the systemd
EnvironmentFile and malform it. Document the value-extraction (zsh -c 'print -r -- $VAR',
since zsh auto-sources ~/.zshenv) + a verify line (exactly one BROKER_SPONSOR_SIGNER_KEY=0x<64hex>),
and flag the hard requirement: the key must be the SAME one whose address is the paymaster's
on-chain brokerSigner, or the paymaster rejects the co-sign (AA34).

* fix: heima-deploy-paymaster.sh — single --create value (cast variadic ate the flags)

The deploy never landed (PAYMASTER_ADDRESS_HEIMA still unset, no paymaster on-chain).
Root cause: `cast send --create <CODE> "constructor(...)" <args…>` is variadic in ARGS,
so cast swallowed the trailing --private-key/--rpc-url/--legacy/--gas-limit flags as
constructor args (same parse error `cast estimate` throws). Pre-concatenate the
ABI-encoded constructor args onto the creation bytecode and pass ONE --create value, so
no positionals follow and the flags parse cleanly. Deploy verified VIABLE off-chain:
`cast estimate --create <init‖ctor>` = 692418 gas (deployer holds 20.99 HEI).

* fix: heima-deploy-paymaster.sh — cast send flags FIRST, --create LAST

The previous fix still failed: `cast send --create <DATA> --private-key …` → "unexpected
argument '--private-key'". cast's `[SIG] [ARGS]…` positionals follow --create's value and
are variadic, so they swallow any trailing flags. Verified: `cast send <flags> --create
<DATA>` (nothing after --create) parses cleanly (reaches the RPC, not a parse error). The
deploy estimate is 692418 gas; this was purely a cast arg-ordering bug.

* fix: heima-deploy-paymaster.sh — --gas-limit on deposit()/setBrokerSigner (Heima estimate)

The paymaster DEPLOYED fine (0xca3655…, brokerSigner matches) but deposit() didn't land:
the deposit cast send had no --gas-limit, so cast fell back to eth_estimateGas — Heima's
flaky path (same reason forge create broke). The deploy line already pins --gas-limit;
add it to deposit() + setBrokerSigner (mirrors erc4337-register-master.sh, which always
pins --gas-limit on Heima). Also: stop swallowing the deposit error (>/dev/null||true) —
capture + dump it, verify by on-chain getDeposit re-read (mixHash-receipt-proof). Re-run
is idempotent: paymaster has code => skip deploy, retry the deposit only.

* agentkeys: record VerifyingPaymaster 0xca3655… (deployed + funded, #225 sponsored accept)

The paymaster is live on Heima mainnet (deploy + 0.2 HEI EntryPoint deposit, brokerSigner =
0x0298Edd…). Record it so the broker can pull the address:
- operator-workstation.env: PAYMASTER_ADDRESS_HEIMA + BROKER_SPONSOR_SIGNER_ADDRESS_HEIMA
  (the broker's accept handler + setup-broker-host.sh read these).
- heima.json contracts[] + the pinning test expected-set + check-deployed-contracts-sync.sh
  mapping + deployed-contracts.md prose.

Tests: chain_profile 19 passed; check-deployed-contracts-sync.sh green (9 contracts in sync).

* docs: runbook — sponsored accept as 3 idempotent steps (local → broker → web)

Shrink the setup to: 1) LOCAL deploy+record+commit (heima-deploy-paymaster.sh, idempotent;
git add+push so the broker can pull), 2) REMOTE broker sponsor key + setup-broker-host.sh
--ref <branch> + restart, 3) LOCAL dev.sh web test + accept. Calls out the three silent
gotchas the operator hit: PAYMASTER_ADDRESS is NOT in broker-sponsor.env (secret-key-only;
the address rides operator-workstation.env → the unit Environment=), --ref is required and
must be the committed branch (not main), and the sponsor key must match the paymaster's
brokerSigner. Verify via `systemctl show`, not a bare shell var.

* feat: heima-deploy-paymaster.sh auto-records + commits a fresh deploy

A fresh paymaster deploy now self-records: it writes the chain profile (jq add/replace
VerifyingPaymaster in contracts[], idempotent — no duplicate) and AUTO-commits + pushes
operator-workstation.env + the profile. This closes the footgun that left the broker
without PAYMASTER_ADDRESS — the broker only sees COMMITTED values, and forgetting the
manual commit/push silently broke the flow.

- FRESH flag set only on a real deploy; a SKIP (already-deployed re-run) commits nothing.
- Surgical: commits ONLY the env + profile paths (not other working-tree changes).
- Opt out: PAYMASTER_NO_COMMIT=1. Non-fatal: deploy already landed on-chain; a VCS hiccup
  warns + tells you to `git push` by hand.
Runbook step 1 updated (drop the manual git add/commit/push).

* feat(ui): pairing requests show the real pairing code + a formatted timestamp

The pairing-request UI showed neither — `pairCode` was a truncated request_id and
`requestedAt` was the hardcoded string "awaiting on-chain approval". The data already
exists in the broker's pairing_requests table (pairing_code TEXT, created_at INTEGER);
it just wasn't plumbed. Vertical slice, no schema change:

- broker: PendingBinding += pairing_code + created_at; pending_bindings() SELECTs them;
  /v1/agent/pending-bindings returns them.
- daemon: pending_binding_to_request emits pairCode = the REAL agent code (so the operator
  can confirm it matches the device) and requestedAt = created_at unix seconds.
- ui: PairingRequest.requestedAt: string→number; pairing.tsx shows a "pairing code" row +
  formats the timestamp (new Date(ts*1000).toLocaleString()).

Tests: broker pairing 18 passed; daemon builds; frontend tsc clean.

* docs+ui: pairing identifiers spec + mark declared-vs-attested in the UI

Addresses three points on the pairing-request screen:

1. Safe to display? Yes — documented per field in the new spec. device_key_hash/D_pub are
   public on-chain identity (showing them IS the cross-verify point); pairing_code is
   one-time + already consumed by the time it reaches the master's pending list; request_id
   is useless without the agent's K10 pop_sig.
2. New spec docs/spec/agent-pairing-data-model.md — the §10.2 flow + every identifier
   (pairing_code 144b master-claim secret vs request_id 192b agent poll-ticket vs the
   attested device_key_hash/D_pub), secret/public, lifecycle, and the master-UI mapping.
   Indexed from arch.md §10.2.
3. device/machine/runtime are DECLARED (self-reported — in fact daemon placeholders), NOT
   attested. pairing.tsx now groups them under "⚠ declared by the runtime · self-reported,
   NOT attested" and the device_key_hash/D_pub under "✓ attested cryptographic identity ·
   cross-check on the agent". Approve on the attested identity, never the declared labels.

tsc clean.

* docs: coherent runbook rewrite — entry-script map + explicit broker-sponsor.env

Reconciles the two competing "start here" stories and spells out the broker secret file:
- Entry-scripts table (cutover / paymaster / broker / web) maps each script to its phase +
  idempotency, so heima-cutover-account-auth.sh is clearly the phase-0 contract-redeploy entry
  (not a second quick-start) and the 3-step sponsored-accept is the live flow on top.
- "What /etc/agentkeys/broker-sponsor.env must contain" table: BROKER_SPONSOR_SIGNER_KEY
  (required secret) + PAYMASTER_ADDRESS_HEIMA (required for sponsored) + optional ENTRYPOINT /
  SPONSOR_ADDRESS. Documents that EnvironmentFile= OVERRIDES the unit Environment= — so pinning
  PAYMASTER here is the reliable path, no commit→push→--ref dance.
- Drops the stale "final mile is open" status + the duplicate cutover Quick start; folds the
  cutover into §A. Adds a Troubleshooting section (503 / invalid-value / AA34 / UnknownSigner /
  forge-create) covering every trap hit during bring-up. Step 1 documents the script's real
  auto-record + auto-commit behavior.

* fix: accept web demo — broker finds cast (502) + auto-select the master passkey

Two blockers in the live web accept:

1. 502 "spawn cast: No such file or directory" — accept_submit shells `cast send handleOps`,
   but the broker (systemd, User=agentkeys, ProtectHome=true) can't see a ~/.foundry/bin cast.
   - accept.rs: resolve cast via AGENTKEYS_CAST_BIN (default "cast"), with a helpful error.
   - setup-broker-host.sh: copy the operator's cast → /usr/local/bin (on the systemd PATH,
     outside home) + pin Environment=AGENTKEYS_CAST_BIN; warn if foundry isn't installed.

2. Passkey picker — getAssertionOverHash had no allowCredentials, so the browser offered every
   localhost passkey and the user had to guess (wrong one → on-chain rejection). Now the master
   credential id is stored at K11 enrollment (ceremony.tsx → localStorage ak_master_cred_id) and
   passed as allowCredentials at accept (App.tsx → getAssertionOverHash), so the browser
   auto-selects the right key. Absent ⇒ full picker (fallback).

Runbook: troubleshooting entries for both. Tests: broker accept 16 passed; frontend tsc clean.

* fix: real pairing decline (was UI-only) + broker auto-installs foundry for accept-submit

Decline was a no-op — it filtered the local list but never told the broker, so the request
reappeared on refresh. Now a real cross-stack decline (J1-gated, NO Touch ID — declining isn't
an on-chain mutation):
- broker: PairingRequestStore::decline (DELETE the claimed-but-unbound row, scoped to the
  claiming operator_omni) + POST /v1/agent/pairing/decline handler + route.
- daemon: /v1/agent/pairing/decline proxy (untyped relay → forward_to_broker, like accept).
- frontend: client.declinePairing + App.declinePairing actually calls it, then refreshes.

cast/foundry on the broker (the recurring 502 'spawn cast'): setup-broker-host.sh now
AUTO-INSTALLS foundry when cast is missing (idempotent, non-fatal), copies cast → /usr/local/bin
(on the systemd PATH; ProtectHome hides ~/.foundry), and pins AGENTKEYS_CAST_BI…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant